diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart deleted file mode 100644 index bf369ae62d..0000000000 --- a/packages/stream_chat/lib/src/client/channel.dart +++ /dev/null @@ -1,4896 +0,0 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'dart:async'; -import 'dart:math' as math; - -import 'package:collection/collection.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:synchronized/synchronized.dart'; - -import '../../stream_chat.dart'; -import '../core/util/utils.dart'; -import 'retry_queue.dart'; - -/// The maximum time the incoming [Event.typingStart] event is valid before a -/// [Event.typingStop] event is emitted automatically. -const incomingTypingStartEventTimeout = 7; - -/// Class that manages a specific channel. -/// -/// #### Channel name -/// -/// {@template name} -/// If an optional [name] argument is provided in the constructor then it -/// will be set on [extraData] with a key of 'name'. -/// -/// ```dart -/// final channel = Channel(client, type, id, name: 'Channel name'); -/// print(channel.name == channel.extraData['name']); // true -/// ``` -/// -/// Before the channel is initialized the name can be set directly: -/// ```dart -/// channel.name = 'New channel name'; -/// ``` -/// -/// To update the name after the channel has been initialized, call: -/// ```dart -/// channel.updateName('Updated channel name'); -/// ``` -/// -/// This will do a partial update to update the name. -/// {@endtemplate} -/// -/// #### Channel image -/// -/// {@template image} -/// If an optional [image] argument is provided in the constructor then it -/// will be set on [extraData] with a key of 'image'. -/// -/// ```dart -/// final channel = Channel(client, type, id, image: 'https://getstream.io/image.png'); -/// print(channel.image == channel.extraData['image']); // true -/// ``` -/// -/// Before the channel is initialized the image can be set directly: -/// ```dart -/// channel.image = 'https://getstream.io/new-image'; -/// ``` -/// -/// To update the image after the channel has been initialized, call: -/// ```dart -/// channel.updateImage('https://getstream.io/new-image'); -/// ``` -/// -/// This will do a partial update to update the image. -/// {@endtemplate} -class Channel { - /// Class that manages a specific channel. - /// - /// Optional [extraData] and [image] properties can be provided. The [image] - /// is exposed to easily set a key of 'image' on [extraData]. - Channel( - this._client, - this._type, - this._id, { - String? name, - String? image, - Map? extraData, - }) : _cid = _id != null ? '$_type:$_id' : null, - _extraData = { - ...?extraData, - if (name != null) 'name': name, - if (image != null) 'image': image, - } { - _client.logger.info('New Channel instance created, not yet initialized'); - } - - /// Create a channel client instance from a [ChannelState] object. - Channel.fromState(this._client, ChannelState channelState) - : assert( - channelState.channel != null, - 'No channel found inside channel state', - ), - _id = channelState.channel!.id, - _type = channelState.channel!.type, - _cid = channelState.channel!.cid, - _extraData = channelState.channel!.extraData { - _initState(channelState); // Initialize the state immediately. - } - - /// This client state - ChannelClientState? state; - - /// The channel type - final String _type; - - String? _id; - String? _cid; - final Map _extraData; - - /// Shortcut to set channel name. - /// - /// {@macro name} - set name(String? name) { - if (_isInitialized) { - throw StateError( - 'Once the channel is initialized you should use `channel.updateName` ' - 'to update the channel name', - ); - } - _extraData.addAll({'name': name}); - } - - /// Shortcut to set channel image. - /// - /// {@macro image} - set image(String? image) { - if (_isInitialized) { - throw StateError( - 'Once the channel is initialized you should use `channel.updateImage` ' - 'to update the channel image', - ); - } - _extraData.addAll({'image': image}); - } - - set extraData(Map extraData) { - if (_isInitialized) { - throw StateError( - 'Once the channel is initialized you should use `channel.update` ' - 'to update channel data', - ); - } - _extraData.addAll(extraData); - } - - /// Whether this channel is identified by its member set rather than an - /// explicit id. - /// - /// Stream auto-generates ids of the form `!members-` for channels - /// created with members but no id, so the same set of users always - /// references the same channel. - /// - /// Distinct channels can lose members but can't gain them after creation. - /// - /// See [isOneToOne] for the typical 1-to-1 predicate built on this. - bool get isDistinct => id?.startsWith('!members') == true; - - /// Whether this is a group channel. - /// - /// True when the channel has more than two members, or isn't [isDistinct]. - /// Custom-id channels are treated as groups regardless of current member - /// count because they aren't bounded — they can grow back into - /// multi-person conversations. - /// - /// Near-inverse of [isOneToOne]. - bool get isGroup => (memberCount ?? 0) > 2 || !isDistinct; - - /// Whether this is a 1-to-1 conversation. - /// - /// True when the channel is [isDistinct] and has exactly two members. - /// Distinct channels can't gain members, so a 2-member distinct channel - /// is permanently bounded to two participants — including channels that - /// shrunk down from a larger group DM. - /// - /// This is a structural predicate without a current-user check. Combine - /// with capability / permission checks at the call site if you need - /// perspective gating. - /// - /// Near-inverse of [isGroup]. - bool get isOneToOne => isDistinct && memberCount == 2; - - /// Returns true if the channel is muted. - bool get isMuted { - final channelMutes = _client.state.currentUser?.channelMutes; - if (channelMutes == null) return false; - - return channelMutes.any((it) => it.channel.cid == cid); - } - - /// Returns true if the channel is muted, as a stream. - Stream get isMutedStream => _client.state.currentUserStream.map((user) { - final channelMutes = user?.channelMutes; - if (channelMutes == null) return false; - - return channelMutes.any((it) => it.channel.cid == cid); - }).distinct(); - - /// Channel configuration. - ChannelConfig? get config { - _checkInitialized(); - return state!._channelState.channel?.config; - } - - /// Channel configuration as a stream. - Stream get configStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.config); - } - - /// Relationship of the current user to this channel. - Member? get membership { - _checkInitialized(); - return state!._channelState.membership; - } - - /// Relationship of the current user to this channel as a stream. - Stream get membershipStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.membership); - } - - /// Channel user creator. - User? get createdBy { - _checkInitialized(); - return state!._channelState.channel?.createdBy; - } - - /// Channel user creator as a stream. - Stream get createdByStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.createdBy); - } - - /// Channel frozen status. - bool get frozen { - _checkInitialized(); - return state!._channelState.channel?.frozen == true; - } - - /// Channel frozen status as a stream. - Stream get frozenStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.frozen == true).distinct(); - } - - /// Channel disabled status. - bool get disabled { - _checkInitialized(); - return state!._channelState.channel?.disabled == true; - } - - /// Channel disabled status as a stream. - Stream get disabledStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.disabled == true).distinct(); - } - - /// Channel hidden status. - bool get hidden { - _checkInitialized(); - return state!._channelState.channel?.hidden == true; - } - - /// Channel hidden status as a stream. - Stream get hiddenStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.hidden == true).distinct(); - } - - /// Channel pinned status. - /// Status is specific to the current user. - bool get isPinned { - _checkInitialized(); - return membership?.pinnedAt != null; - } - - /// Channel pinned status as a stream. - /// Status is specific to the current user. - Stream get isPinnedStream { - return membershipStream.map((m) => m?.pinnedAt != null).distinct(); - } - - /// Channel archived status. - /// Status is specific to the current user. - bool get isArchived { - _checkInitialized(); - return membership?.archivedAt != null; - } - - /// Channel archived status as a stream. - /// Status is specific to the current user. - Stream get isArchivedStream { - return membershipStream.map((m) => m?.archivedAt != null).distinct(); - } - - /// The last date at which the channel got truncated. - DateTime? get truncatedAt { - _checkInitialized(); - return state!._channelState.channel?.truncatedAt; - } - - /// The last date at which the channel got truncated as a stream. - Stream get truncatedAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.truncatedAt).distinct(); - } - - /// Cooldown count - int get cooldown { - _checkInitialized(); - return state!._channelState.channel?.cooldown ?? 0; - } - - /// Cooldown count as a stream - Stream get cooldownStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.cooldown ?? 0).distinct(); - } - - /// Remaining cooldown duration in seconds for the channel. - /// - /// Returns 0 if there is no cooldown active. - /// - /// Optionally, provide [lastMessageAt] to calculate the remaining cooldown based on a specific message timestamp - /// instead of the last message sent by the current user in this channel. - int getRemainingCooldown({DateTime? lastMessageAt}) { - _checkInitialized(); - - final cooldownDuration = cooldown; - if (cooldownDuration <= 0) return 0; - - final userLastMessageAt = lastMessageAt ?? currentUserLastMessageAt; - if (userLastMessageAt == null) return 0; - - if (canSkipSlowMode) return 0; - - final currentTime = DateTime.timestamp(); - final elapsedTime = currentTime.difference(userLastMessageAt).inSeconds; - - return math.max(0, cooldownDuration - elapsedTime); - } - - /// Channel creation date. - DateTime? get createdAt { - _checkInitialized(); - return state!._channelState.channel?.createdAt; - } - - /// Channel creation date as a stream. - Stream get createdAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.createdAt).distinct(); - } - - /// Channel last message date. - DateTime? get lastMessageAt { - _checkInitialized(); - return state!._channelState.channel?.lastMessageAt; - } - - /// Channel last message date as a stream. - Stream get lastMessageAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt).distinct(); - } - - DateTime? _currentUserLastMessageAt({ - required List? messages, - required Map> threads, - }) { - final currentUserId = client.state.currentUser?.id; - if (currentUserId == null) return null; - - bool ours(Message m) => !m.isEphemeral && m.user?.id == currentUserId; - - DateTime? max; - - if (messages != null) { - final idx = messages.lastIndexWhere(ours); - if (idx != -1) max = messages[idx].createdAt; - } - - for (final replies in threads.values) { - final idx = replies.lastIndexWhere(ours); - if (idx == -1) continue; - final createdAt = replies[idx].createdAt; - if (max == null || createdAt.isAfter(max)) max = createdAt; - } - - return max; - } - - /// The date of the last message sent by the current user. - /// - /// Returns null if the channel is not up to date or - /// if the current user has not sent any messages in this channel. - /// - /// Note: This includes both regular messages and thread messages. - DateTime? get currentUserLastMessageAt { - _checkInitialized(); - - // If the channel is not up to date, we can't rely on the last message - // from the current user. - if (!state!.isUpToDate) return null; - - final threads = state!.threads; - final messages = state!.channelState.messages; - - return _currentUserLastMessageAt(messages: messages, threads: threads); - } - - /// The date of the last message sent by the current user as a stream. - /// - /// Returns null if the channel is not up to date or - /// if the current user has not sent any messages in this channel. - /// - /// Note: This includes both regular messages and thread messages. - Stream get currentUserLastMessageAtStream { - _checkInitialized(); - - return CombineLatestStream.combine3( - state!.isUpToDateStream, - state!.channelStateStream.map((s) => s.messages).distinct(identical), - state!.threadsStream, - (isUpToDate, messages, threads) { - // If the channel is not up to date, we can't rely on the last message - // from the current user. - if (!isUpToDate) return null; - - return _currentUserLastMessageAt(messages: messages, threads: threads); - }, - ); - } - - /// Channel updated date. - DateTime? get updatedAt { - _checkInitialized(); - return state!._channelState.channel?.updatedAt; - } - - /// Channel updated date as a stream. - Stream get updatedAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.updatedAt).distinct(); - } - - /// Channel deletion date. - DateTime? get deletedAt { - _checkInitialized(); - return state!._channelState.channel?.deletedAt; - } - - /// Channel deletion date as a stream. - Stream get deletedAtStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.deletedAt).distinct(); - } - - /// Channel member count. - int? get memberCount { - _checkInitialized(); - return state!._channelState.channel?.memberCount; - } - - /// Channel member count as a stream. - Stream get memberCountStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.memberCount).distinct(); - } - - /// Channel message count. - /// - /// Note: This field is only populated if the `count_messages` option is - /// enabled for your app. - int? get messageCount { - _checkInitialized(); - return state!._channelState.channel?.messageCount; - } - - /// Channel message count as a stream. - /// - /// Note: This field is only populated if the `count_messages` option is - /// enabled for your app. - Stream get messageCountStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.messageCount).distinct(); - } - - /// List of filter tags applied to this channel. - /// - /// Generally used for filtering channels while querying. - List? get filterTags { - _checkInitialized(); - return state!._channelState.channel?.filterTags; - } - - /// Channel id. - String? get id => state?._channelState.channel?.id ?? _id; - - /// Channel type. - String get type => state?._channelState.channel?.type ?? _type; - - /// Channel cid. - String? get cid => state?._channelState.channel?.cid ?? _cid; - - /// Channel team. - String? get team { - _checkInitialized(); - return state!._channelState.channel?.team; - } - - /// Channel extra data. - Map get extraData { - var data = state?._channelState.channel?.extraData; - if (data == null || data.isEmpty) { - data = _extraData; - } - return data; - } - - /// List of user permissions on this channel - List get ownCapabilities => state?._channelState.channel?.ownCapabilities ?? []; - - /// List of user permissions on this channel - Stream> get ownCapabilitiesStream { - _checkInitialized(); - return state!.channelStateStream.map((cs) => cs.channel?.ownCapabilities ?? []).distinct(); - } - - /// Channel extra data as a stream. - Stream> get extraDataStream { - _checkInitialized(); - return state!.channelStateStream.map( - (cs) => cs.channel?.extraData ?? _extraData, - ); - } - - /// Shortcut to get channel name. - /// - /// {@macro name} - String? get name => extraData['name'] as String?; - - /// Channel [name] as a stream. - /// - /// The channel needs to be initialized. - /// - /// {@macro name} - Stream get nameStream { - _checkInitialized(); - return extraDataStream.map((it) => it['name'] as String?).distinct(); - } - - /// Shortcut to get channel image. - /// - /// {@macro image} - String? get image => extraData['image'] as String?; - - /// Channel [image] as a stream. - /// - /// The channel needs to be initialized. - /// - /// {@macro image} - Stream get imageStream { - _checkInitialized(); - return extraDataStream.map((it) => it['image'] as String?).distinct(); - } - - /// The main Stream chat client. - StreamChatClient get client => _client; - final StreamChatClient _client; - - Completer _initializedCompleter = Completer(); - - /// True if this is initialized. - /// - /// Call [watch] to initialize the client or instantiate it using - /// [Channel.fromState]. - Future get initialized => _initializedCompleter.future; - - // Whether the channel is successfully initialized and not disposed. - bool get _isInitialized => _initializedCompleter.isCompleted && state != null; - - final _cancelableAttachmentUploadRequest = {}; - final _messageAttachmentsUploadCompleter = >{}; - - /// Cancels [attachmentId] upload request. Throws exception if the request - /// hasn't even started yet, Already completed or Already cancelled. - /// - /// Optionally, provide a [reason] for the cancellation. - void cancelAttachmentUpload( - String attachmentId, { - String? reason, - }) { - final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; - if (cancelToken == null) { - throw const StreamChatError( - "Upload request for this Attachment hasn't started yet or maybe " - 'Already completed', - ); - } - if (cancelToken.isCancelled) { - throw const StreamChatError('Upload request already cancelled'); - } - cancelToken.cancel(reason); - } - - /// Retries the failed [attachmentId] upload request. - Future retryAttachmentUpload(String messageId, String attachmentId) => - _uploadAttachments(messageId, [attachmentId]); - - Future _uploadAttachments( - String messageId, - Iterable attachmentIds, - ) { - var message = [ - ...state!.messages, - ...state!.threads.values.expand((messages) => messages), - ].firstWhereOrNull((it) => it.id == messageId); - - if (message == null) { - throw const StreamChatError('Error, Message not found'); - } - - final attachments = message.attachments.where((it) { - if (it.uploadState.isSuccess) return false; - return attachmentIds.contains(it.id); - }); - - if (attachments.isEmpty) { - client.logger.info('No attachments available to upload'); - if (message.attachments.every((it) => it.uploadState.isSuccess)) { - _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); - } - return Future.value(); - } - - client.logger.info('Found ${attachments.length} attachments'); - - void updateAttachment(Attachment attachment, {bool remove = false}) { - final index = message!.attachments.indexWhere( - (it) => it.id == attachment.id, - ); - if (index != -1) { - // update or remove attachment from message. - final List newAttachments; - if (remove) { - newAttachments = [...message!.attachments]..removeAt(index); - } else { - newAttachments = [...message!.attachments]..[index] = attachment; - } - - final updatedMessage = message!.copyWith(attachments: newAttachments); - state?.updateMessage(updatedMessage); - // updating original message for next iteration - message = message!.merge(updatedMessage); - } - } - - return Future.wait( - attachments.map((it) { - client.logger.info('Uploading ${it.id} attachment...'); - - final throttledUpdateAttachment = updateAttachment.throttled( - const Duration(milliseconds: 500), - ); - - void onSendProgress(int sent, int total) { - throttledUpdateAttachment([ - it.copyWith( - uploadState: UploadState.inProgress(uploaded: sent, total: total), - ), - ]); - } - - final isImage = it.type == AttachmentType.image; - final cancelToken = CancelToken(); - Future future; - if (isImage) { - future = sendImage( - it.file!, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: it.extraData, - ); - } else { - future = sendFile( - it.file!, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: it.extraData, - ); - } - _cancelableAttachmentUploadRequest[it.id] = cancelToken; - return future - .then((response) { - client.logger.info('Attachment ${it.id} uploaded successfully...'); - - // If the response is SendFileResponse, then we might also be getting - // thumbUrl in case of video. So we need to update the attachment with - // both the assetUrl and thumbUrl. - if (response is SendFileResponse) { - updateAttachment( - it.copyWith( - assetUrl: response.file, - thumbUrl: response.thumbUrl, - uploadState: const UploadState.success(), - ), - ); - } else { - updateAttachment( - it.copyWith( - imageUrl: response.file, - uploadState: const UploadState.success(), - ), - ); - } - }) - .catchError((e, stk) { - if (e is StreamChatNetworkError && e.type == .cancel) { - client.logger.info('Attachment ${it.id} upload cancelled'); - - // remove attachment from message if cancelled. - updateAttachment(it, remove: true); - return; - } - - client.logger.severe('error uploading the attachment', e, stk); - updateAttachment( - it.copyWith(uploadState: UploadState.failed(error: e.toString())), - ); - }) - .whenComplete(() { - throttledUpdateAttachment.cancel(); - _cancelableAttachmentUploadRequest.remove(it.id); - }); - }), - ).whenComplete(() { - final completer = _messageAttachmentsUploadCompleter.remove(messageId); - if (completer == null || completer.isCompleted) return; - - // Always complete with the latest message view so callers can decide - // success vs. partial failure by inspecting per-attachment upload - // states. Cancellation is still surfaced via `completeError` from the - // sendMessage/updateMessage/deleteMessage entry points. - completer.complete(message); - }); - } - - final _sendMessageLock = Lock(); - - /// Send a [message] to this channel. - /// - /// If [skipPush] is true the message will not send a push notification. - /// - /// Waits for a [_messageAttachmentsUploadCompleter] to complete - /// before actually sending the message. - Future sendMessage( - Message message, { - bool skipPush = false, - bool skipEnrichUrl = false, - }) async { - _checkInitialized(); - - // Clean up stale error messages before sending a new message. - state?.cleanUpStaleErrorMessages(); - - // Cancelling previous completer in case it's called again in the process - // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); - - final quotedMessage = state!.messages.firstWhereOrNull( - (m) => m.id == message.quotedMessageId, - ); - // ignore: parameter_assignments - message = message.copyWith( - localCreatedAt: DateTime.now(), - user: _client.state.currentUser, - quotedMessage: quotedMessage, - state: MessageState.sending, - attachments: message.attachments.map( - (it) { - if (it.uploadState.isSuccess) return it; - return it.copyWith(uploadState: const UploadState.preparing()); - }, - ).toList(), - ); - - state?.updateMessage(message); - - try { - if (message.attachments.any((it) => !it.uploadState.isSuccess)) { - final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - - _uploadAttachments( - message.id, - message.attachments.map((it) => it.id), - ); - - // ignore: parameter_assignments - message = await attachmentsUploadCompleter.future; - - // Fail the whole message if any attachment failed to upload - if (message.attachments.any((it) => it.uploadState.isFailed)) { - throw const StreamChatError('Failed to upload one or more attachments'); - } - } - - // Validate the final message before sending it to the server. - if (MessageRules.canUpload(message) != true) { - client.logger.warning('Message is not valid for sending, removing it'); - - // Remove the message from state as it is invalid. - state!.deleteMessage(message, hardDelete: true); - throw const StreamChatError('Message is not valid for sending'); - } - - // Wait for the previous sendMessage call to finish. Otherwise, the order - // of messages will not be maintained. - final response = await _sendMessageLock.synchronized( - () => _client.sendMessage( - message, - id!, - type, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - final sentMessage = message - .updateWith(response.message) - .copyWith( - // Update the message state to sent. - state: MessageState.sent, - ); - - state?.updateMessage(sentMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.sendingFailed( - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - state?.updateMessage(failedMessage); - // If the error is retriable, add it to the retry queue. - if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); - } - - rethrow; - } - } - - final _updateMessageLock = Lock(); - - /// Updates the [message] in this channel. - /// - /// Waits for a [_messageAttachmentsUploadCompleter] to complete - /// before actually updating the message. - Future updateMessage( - Message message, { - bool skipPush = false, - bool skipEnrichUrl = false, - }) async { - _checkInitialized(); - - // Cancelling previous completer in case it's called again in the process - // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); - - // ignore: parameter_assignments - message = message.copyWith( - state: MessageState.updating, - localUpdatedAt: DateTime.now(), - attachments: message.attachments.map( - (it) { - if (it.uploadState.isSuccess) return it; - return it.copyWith(uploadState: const UploadState.preparing()); - }, - ).toList(), - ); - - state?.updateMessage(message); - - try { - if (message.attachments.any((it) => !it.uploadState.isSuccess)) { - final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; - - _uploadAttachments( - message.id, - message.attachments.map((it) => it.id), - ); - - // ignore: parameter_assignments - message = await attachmentsUploadCompleter.future; - - // Fail the whole message if any attachment failed to upload - if (message.attachments.any((it) => it.uploadState.isFailed)) { - throw const StreamChatError('Failed to upload one or more attachments'); - } - } - - // Wait for the previous update call to finish. Otherwise, the order of - // messages will not be maintained. - final response = await _updateMessageLock.synchronized( - () => _client.updateMessage( - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - final updateMessage = message - .updateWith(response.message) - .copyWith( - // Update the message state to updated. - state: MessageState.updated, - ); - - state?.updateMessage(updateMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.updatingFailed( - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - state?.updateMessage(failedMessage); - // If the error is retriable, add it to the retry queue. - if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); - } - - rethrow; - } - } - - /// Partially updates the [message] in this channel. - /// - /// Use [set] to define values to be set. - /// - /// Use [unset] to define values to be unset. - Future partialUpdateMessage( - Message message, { - Map? set, - List? unset, - bool skipEnrichUrl = false, - }) async { - _checkInitialized(); - - // Cancelling previous completer in case it's called again in the process - // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); - - // ignore: parameter_assignments - message = message.copyWith( - state: MessageState.updating, - localUpdatedAt: DateTime.now(), - ); - - state?.updateMessage(message); - - try { - // Wait for the previous update call to finish. Otherwise, the order of - // messages will not be maintained. - final response = await _updateMessageLock.synchronized( - () => _client.partialUpdateMessage( - message.id, - set: set, - unset: unset, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - final updatedMessage = message - .updateWith(response.message) - .copyWith( - // Update the message state to updated. - state: MessageState.updated, - ); - - state?.updateMessage(updatedMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.partialUpdatingFailed( - set: set, - unset: unset, - skipEnrichUrl: skipEnrichUrl, - ), - ); - - state?.updateMessage(failedMessage); - // If the error is retriable, add it to the retry queue. - if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); - } - - rethrow; - } - } - - final _deleteMessageLock = Lock(); - - /// Deletes the [message] for everyone. - /// - /// If [hard] is true, the message is permanently deleted from the server - /// and cannot be recovered. In this case, any attachments associated with the - /// message are also deleted from the server. - Future deleteMessage(Message message, {bool hard = false}) { - final deletionScope = MessageDeleteScope.deleteForAll(hard: hard); - - return _deleteMessage(message, scope: deletionScope); - } - - /// Deletes the [message] only for the current user. - /// - /// Note: This does not delete the message for other channel members and - /// they can still see the message. - Future deleteMessageForMe(Message message) { - const deletionScope = MessageDeleteScope.deleteForMe(); - - return _deleteMessage(message, scope: deletionScope); - } - - // Deletes the [message] from the channel. - // - // The [scope] defines whether to delete the message for everyone or just - // for the current user. - // - // If the message is a local message (not yet sent to the server) or a bounced - // error message, it is deleted locally without making an API call. - // - // If the message is deleted for everyone and [scope.hard] is true, the - // message is permanently deleted from the server and cannot be recovered. - // In this case, any attachments associated with the message are also deleted - // from the server. - Future _deleteMessage( - Message message, { - required MessageDeleteScope scope, - }) async { - _checkInitialized(); - - // Directly deleting the local messages and bounced error messages as they - // are not available on the server. - if (message.remoteCreatedAt == null || message.isBouncedWithError) { - _deleteLocalMessage(message); - // Returning empty response to mark the api call as success. - return EmptyResponse(); - } - - // ignore: parameter_assignments - message = message.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.now(), - deletedForMe: scope is DeleteForMe, - state: MessageState.deleting(scope: scope), - ); - - state?.deleteMessage(message, hardDelete: scope.hard); - - try { - // Wait for the previous delete call to finish. Otherwise, the order of - // messages will not be maintained. - final response = await _deleteMessageLock.synchronized( - () => switch (scope) { - DeleteForMe() => _client.deleteMessageForMe(message.id), - DeleteForAll() => _client.deleteMessage(message.id, hard: scope.hard), - }, - ); - - final deletedMessage = message.copyWith( - deletedForMe: scope is DeleteForMe, - state: MessageState.deleted(scope: scope), - ); - - state?.deleteMessage(deletedMessage, hardDelete: scope.hard); - // If hard delete, also delete the attachments from the server. - if (scope.hard) _deleteMessageAttachments(deletedMessage); - - return response; - } catch (e) { - final failedMessage = message.copyWith( - // Update the message state to failed. - state: MessageState.deletingFailed(scope: scope), - ); - - state?.deleteMessage(failedMessage, hardDelete: scope.hard); - // If the error is retriable, add it to the retry queue. - if (e is StreamChatNetworkError && e.isRetriable) { - state?._retryQueue.add([failedMessage]); - } - - rethrow; - } - } - - // Deletes a local [message] that is not yet sent to the server. - // - // This is typically called when a user wants to delete a message that they - // have composed but not yet sent, or if a message failed to send and the user - // wants to remove it from their local view. - void _deleteLocalMessage(Message message) { - state?.deleteMessage( - hardDelete: true, // Local messages are always hard deleted. - message.copyWith( - type: MessageType.deleted, - localDeletedAt: DateTime.now(), - state: MessageState.hardDeleted, - ), - ); - - // Removing the attachments upload completer to stop the `sendMessage` - // waiting for attachments to complete. - final completer = _messageAttachmentsUploadCompleter.remove(message.id); - completer?.completeError(const StreamChatError('Message deleted')); - } - - // Deletes all the attachments associated with the given [message] - // from the server. This is typically called when a message is hard deleted. - Future _deleteMessageAttachments(Message message) async { - final attachments = message.attachments; - final deleteFutures = attachments.map((it) async { - if (it.imageUrl case final url?) return deleteImage(url); - if (it.assetUrl case final url?) return deleteFile(url); - }); - - try { - await Future.wait(deleteFutures); - } catch (e, stk) { - _client.logger.warning('Error deleting message attachments', e, stk); - } - } - - /// Retries operations on a message based on its failed state. - /// - /// This method examines the message's state and performs the appropriate - /// retry action: - /// - For [MessageState.sendingFailed], it attempts to send the message. - /// - For [MessageState.updatingFailed], it attempts to update the message. - /// - For [MessageState.partialUpdatingFailed], it attempts to partially - /// update the message with the same 'set' and 'unset' parameters that were - /// used in the original request. - /// - For [MessageState.deletingFailed], it attempts to delete the message - /// again, using the same scope (for me or for all) as the original request. - /// - For messages with [isBouncedWithError], it attempts to send the message. - /// - /// Throws a [StateError] if the message is not in a failed state or - /// bounced with an error. - Future retryMessage(Message message) async { - assert( - message.state.isFailed || message.isBouncedWithError, - 'Only failed or bounced messages can be retried', - ); - - return message.state.maybeWhen( - failed: (state, _) => state.when( - sendingFailed: (skipPush, skipEnrichUrl) => sendMessage( - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - updatingFailed: (skipPush, skipEnrichUrl) => updateMessage( - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ), - partialUpdatingFailed: (set, unset, skipEnrichUrl) { - return partialUpdateMessage( - message, - set: set, - unset: unset, - skipEnrichUrl: skipEnrichUrl, - ); - }, - deletingFailed: (scope) => switch (scope) { - DeleteForMe() => deleteMessageForMe(message), - DeleteForAll(hard: final hard) => deleteMessage(message, hard: hard), - }, - ), - orElse: () { - // Check if the message is bounced with error. - if (message.isBouncedWithError) return sendMessage(message); - - throw StateError( - 'Only failed or bounced messages can be retried', - ); - }, - ); - } - - /// Pins provided message - Future pinMessage( - Message message, { - 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( - message, - set: { - 'pinned': true, - 'pin_expires': pinExpires?.toUtc().toIso8601String(), - }, - ); - } - - /// Unpins provided message. - Future unpinMessage(Message message) => partialUpdateMessage( - message, - set: { - 'pinned': false, - }, - ); - - /// Creates or updates a new [draft] for this channel. - Future createDraft( - DraftMessage draft, - ) { - _checkInitialized(); - return _client.createDraft(draft, id!, type); - } - - /// Retrieves the draft for this channel. - /// - /// Optionally, provide a [parentId] to get the draft for a specific thread. - Future getDraft({ - String? parentId, - }) { - _checkInitialized(); - return _client.getDraft(id!, type, parentId: parentId); - } - - /// Deletes the draft for this channel. - /// - /// Optionally, provide a [parentId] to delete the draft for a specific - /// thread. - Future deleteDraft({ - String? parentId, - }) { - _checkInitialized(); - return _client.deleteDraft(id!, type, parentId: parentId); - } - - /// Sends a static location to this channel. - /// - /// Optionally, provide a [messageText] and [extraData] to send along with - /// the location. - Future sendStaticLocation({ - String? id, - String? messageText, - String? createdByDeviceId, - required LocationCoordinates location, - Map extraData = const {}, - }) { - final message = Message( - id: id, - text: messageText, - extraData: extraData, - ); - - final currentUserId = _client.state.currentUser?.id; - final locationMessage = message.copyWith( - sharedLocation: Location( - channelCid: cid, - userId: currentUserId, - messageId: message.id, - latitude: location.latitude, - longitude: location.longitude, - createdByDeviceId: createdByDeviceId, - ), - ); - - return sendMessage(locationMessage); - } - - /// Sends a live location sharing message to this channel. - /// - /// Optionally, provide a [messageText] and [extraData] to send along with - /// the location. - Future startLiveLocationSharing({ - String? id, - String? messageText, - String? createdByDeviceId, - required DateTime endSharingAt, - required LocationCoordinates location, - Map extraData = const {}, - }) { - final message = Message( - id: id, - text: messageText, - extraData: extraData, - ); - - final currentUserId = _client.state.currentUser?.id; - final locationMessage = message.copyWith( - sharedLocation: Location( - channelCid: cid, - userId: currentUserId, - messageId: message.id, - endAt: endSharingAt, - latitude: location.latitude, - longitude: location.longitude, - createdByDeviceId: createdByDeviceId, - ), - ); - - return sendMessage(locationMessage); - } - - /// Send a file to this channel. - Future sendFile( - AttachmentFile file, { - ProgressCallback? onSendProgress, - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.sendFile( - file, - id!, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Send an image to this channel. - Future sendImage( - AttachmentFile file, { - ProgressCallback? onSendProgress, - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.sendImage( - file, - id!, - type, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Search for a message with the given options. - Future search({ - String? query, - Filter? messageFilters, - List? sort, - PaginationParams? paginationParams, - }) { - _checkInitialized(); - return _client.search( - Filter.in_('cid', [cid!]), - sort: sort, - query: query, - paginationParams: paginationParams, - messageFilters: messageFilters, - ); - } - - /// Delete a file from this channel. - Future deleteFile( - String url, { - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.deleteFile( - url, - id!, - type, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Delete an image from this channel. - Future deleteImage( - String url, { - CancelToken? cancelToken, - Map? extraData, - }) { - _checkInitialized(); - return _client.deleteImage( - url, - id!, - type, - cancelToken: cancelToken, - extraData: extraData, - ); - } - - /// Send an event on this channel. - Future sendEvent(Event event) { - _checkInitialized(); - return _client.sendEvent(id!, type, event); - } - - final _pollLock = Lock(); - - /// Send a message with a poll to this channel. - /// - /// Optionally provide a [messageText] to send a message along with the poll. - Future sendPoll( - Poll poll, { - String? messageText, - }) async { - _checkInitialized(); - final res = await _pollLock.synchronized(() => _client.createPoll(poll)); - return sendMessage( - Message( - text: messageText, - poll: res.poll, - pollId: res.poll.id, - ), - ); - } - - /// Updates the [poll] in this channel. - Future updatePoll(Poll poll) { - _checkInitialized(); - return _pollLock.synchronized(() => _client.updatePoll(poll)); - } - - /// Deletes the given [poll] from this channel. - Future deletePoll(Poll poll) { - _checkInitialized(); - return _pollLock.synchronized(() => _client.deletePoll(poll.id)); - } - - /// Close the given [poll]. - Future closePoll(Poll poll) { - _checkInitialized(); - return _pollLock.synchronized(() => _client.closePoll(poll.id)); - } - - /// Create a new poll option for the given [poll]. - Future createPollOption( - Poll poll, - PollOption option, - ) { - _checkInitialized(); - return _pollLock.synchronized( - () => _client.createPollOption(poll.id, option), - ); - } - - final _pollVoteLock = Lock(); - - /// Cast a vote on the given [poll] with the given [option]. - Future castPollVote( - Message message, - Poll poll, - PollOption option, - ) async { - _checkInitialized(); - - final optionId = option.id; - if (optionId == null) { - throw ArgumentError('Option id cannot be null'); - } - - return _pollVoteLock.synchronized( - () => _client.castPollVote( - message.id, - poll.id, - optionId: optionId, - ), - ); - } - - /// Add a new answer to the given [poll]. - Future addPollAnswer( - Message message, - Poll poll, { - required String answerText, - }) { - _checkInitialized(); - return _pollVoteLock.synchronized( - () => _client.addPollAnswer( - message.id, - poll.id, - answerText: answerText, - ), - ); - } - - /// Remove a vote on the given [poll] with the given [vote]. - Future removePollVote( - Message message, - Poll poll, - PollVote vote, - ) { - _checkInitialized(); - - final voteId = vote.id; - if (voteId == null) { - throw ArgumentError('Vote id cannot be null'); - } - - return _pollVoteLock.synchronized( - () => _client.removePollVote( - message.id, - poll.id, - voteId, - ), - ); - } - - /// Query the poll votes for the given [pollId] with the given [filter] and - /// [sort] options. - Future queryPollVotes( - String pollId, { - Filter? filter, - SortOrder? sort, - PaginationParams pagination = const PaginationParams(), - }) { - _checkInitialized(); - return _client.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - } - - /// Create a reminder for the given [messageId]. - /// - /// Optionally, provide a [remindAt] date to set when the reminder should - /// be triggered. If not provided, the reminder will be created as a - /// bookmark type instead. - Future createReminder( - String messageId, { - DateTime? remindAt, - }) { - _checkInitialized(); - return _client.createReminder( - messageId, - remindAt: remindAt, - ); - } - - /// Update an existing reminder with the given [reminderId]. - /// - /// Optionally, provide a [remindAt] date to set when the reminder should - /// be triggered. If not provided, the reminder will be updated as a - /// bookmark type instead. - Future updateReminder( - String messageId, { - DateTime? remindAt, - }) { - _checkInitialized(); - return _client.updateReminder( - messageId, - remindAt: remindAt, - ); - } - - /// Remove the reminder for the given [messageId]. - Future deleteReminder(String messageId) { - _checkInitialized(); - return _client.deleteReminder(messageId); - } - - /// Send a reaction to this channel. - /// - /// Set [enforceUnique] to true to remove the existing user reaction. - Future sendReaction( - Message message, - Reaction reaction, { - bool skipPush = false, - bool enforceUnique = false, - }) async { - _checkInitialized(); - - final messageId = message.id; - // ignore: parameter_assignments - reaction = reaction.copyWith( - messageId: messageId, - user: _client.state.currentUser, - ); - - final updatedMessage = message.addMyReaction( - reaction, - enforceUnique: enforceUnique, - ); - - state?.updateMessage(updatedMessage); - - try { - final reactionResp = await _client.sendReaction( - messageId, - reaction, - skipPush: skipPush, - enforceUnique: enforceUnique, - ); - return reactionResp; - } catch (_) { - // Reset the message if the update fails. Use replace (not merge) - // so the rollback wins over the optimistic local state — otherwise - // `Message.updateWith`'s enrichment preservation would keep the - // optimistic `ownReactions` for messages that previously had none. - state?.replaceMessage(message); - rethrow; - } - } - - /// Delete a reaction from this channel. - Future deleteReaction( - Message message, - Reaction reaction, - ) async { - _checkInitialized(); - - final updatedMessage = message.deleteMyReaction( - reactionType: reaction.type, - ); - - state?.updateMessage(updatedMessage); - - try { - final deleteResponse = await _client.deleteReaction( - message.id, - reaction.type, - ); - return deleteResponse; - } catch (_) { - // Reset the message if the update fails. Use replace (not merge) - // for symmetry with `sendReaction` — see that method for context. - state?.replaceMessage(message); - rethrow; - } - } - - /// Sends an event to stop AI response generation, leaving the message in - /// its current state. - Future stopAIResponse() async { - return sendEvent( - Event( - type: EventType.aiIndicatorStop, - ), - ); - } - - /// Update the channel's [name]. - /// - /// This is the same as calling [updatePartial] and providing a map with a - /// 'name' key: - /// - /// ```dart - /// channel.updatePartial( - /// set: {'name': 'Updated channel name'} - /// ); - /// ``` - /// - /// Instead do: - /// ```dart - /// channel.updateName('Updated channel name'); - /// ``` - Future updateName(String name) => updatePartial(set: {'name': name}); - - /// Update the channel's [image]. - /// - /// This is the same as calling [updatePartial] and providing a map with an - /// 'image' key: - /// - /// ```dart - /// channel.updatePartial( - /// set: {'image': 'https://getstream.io/new-image'} - /// ); - /// ``` - /// - /// Instead do: - /// ```dart - /// channel.updateImage('https://getstream.io/new-image'); - /// ``` - Future updateImage(String image) => updatePartial(set: {'image': image}); - - /// Update the channel custom data. This replaces all of the channel data - /// with the given [channelData]. - /// - /// If you instead want to do a partial update, use [updatePartial]. - /// - /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart - /// for more information. - Future update( - Map channelData, { - Message? updateMessage, - }) async { - _checkInitialized(); - return _client.updateChannel( - id!, - type, - channelData, - message: updateMessage, - ); - } - - /// A partial update can be used to set and unset specific custom data fields - /// when it is necessary to retain additional custom data fields on the - /// object. - /// - /// - [set] will add, or update existing attributes. - /// - [unset] will remove the attributes with the provided list of - /// values (keys). - /// - /// If you want to do a full update/replacement, use [update] instead. - /// - /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart - /// for more information. - Future updatePartial({ - Map? set, - List? unset, - }) async { - _checkInitialized(); - return _client.updateChannelPartial(id!, type, set: set, unset: unset); - } - - /// Enable slow mode - Future enableSlowMode({ - required int cooldownInterval, - }) async { - _checkInitialized(); - return _client.enableSlowdown(id!, type, cooldownInterval); - } - - /// Disable slow mode - Future disableSlowMode() async { - _checkInitialized(); - return _client.disableSlowdown(id!, type); - } - - /// Delete this channel. Messages are permanently removed. - Future delete() async { - _checkInitialized(); - return _client.deleteChannel(id!, type); - } - - /// Removes all messages from the channel up to [truncatedAt] or now if - /// [truncatedAt] is not provided. - /// If [skipPush] is true, no push notification will be sent. - /// [Message] is the system message that will be sent to the channel. - Future truncate({ - Message? message, - bool? skipPush, - DateTime? truncatedAt, - }) async { - _checkInitialized(); - return _client.truncateChannel( - id!, - type, - message: message, - skipPush: skipPush, - truncatedAt: truncatedAt, - ); - } - - /// Accept invitation to the channel. - Future acceptInvite([Message? message]) async { - _checkInitialized(); - return _client.acceptChannelInvite(id!, type, message: message); - } - - /// Reject invitation to the channel. - Future rejectInvite([Message? message]) async { - _checkInitialized(); - return _client.rejectChannelInvite(id!, type, message: message); - } - - /// Add members to the channel. - Future addMembers( - List memberIds, { - Message? message, - bool hideHistory = false, - DateTime? hideHistoryBefore, - }) async { - _checkInitialized(); - return _client.addChannelMembers( - id!, - type, - memberIds, - message: message, - hideHistory: hideHistory, - hideHistoryBefore: hideHistoryBefore, - ); - } - - /// Invite members to the channel. - Future inviteMembers( - List memberIds, { - Message? message, - }) async { - _checkInitialized(); - return _client.inviteChannelMembers(id!, type, memberIds, message: message); - } - - /// Remove members from the channel. - Future removeMembers( - List memberIds, { - Message? message, - }) async { - _checkInitialized(); - return _client.removeChannelMembers(id!, type, memberIds, message: message); - } - - /// Send action for a specific message of this channel. - Future sendAction( - Message message, - Map formData, - ) async { - _checkInitialized(); - final messageId = message.id; - final res = await _client.sendAction(id!, type, messageId, formData); - - // update the passed message with response message - if (res.message != null) { - state!.updateMessage(res.message!); - } else { - // remove the passed message if response does - // not contain message - state!.removeMessage(message); - } - return res; - } - - /// Mark all messages as read. - /// - /// Optionally provide a [messageId] if you want to mark channel as - /// read from a particular message onwards. - /// - /// If [usesLocalUnreadCount] is `true` for this channel, this updates the - /// unread count locally, on-device, without making a network request. In - /// that case [messageId] is recorded as the read boundary but does **not** - /// narrow the count: the channel is always treated as fully read and the - /// count drops to zero. See [ChannelClientState.markReadLocally]. - Future markRead({String? messageId}) async { - _checkInitialized(); - - if (usesLocalUnreadCount) { - state!.markReadLocally(messageId: messageId); - return EmptyResponse(); - } - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark as read: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markChannelRead(id!, type, messageId: messageId); - } - - /// Marks the channel as unread by a given [messageId]. - /// - /// All messages from the provided message onwards will be marked as unread, - /// **including** the message itself. Contrast with - /// [markUnreadByTimestamp], which is exclusive: a message created at - /// exactly the given timestamp stays read. - /// - /// If [usesLocalUnreadCount] is `true` for this channel, this updates the - /// unread count locally, on-device, without making a network request. The - /// message must be part of the locally-known messages ([Channel.messages]) - /// for the count to be recomputed. - Future markUnread(String messageId) async { - _checkInitialized(); - - if (usesLocalUnreadCount) { - // [ChannelClientState.messages] is sorted ascending by `createdAt`, so - // the entry before the anchor is the newest message that stays read. - final messages = state!.messages; - final anchorIndex = messages.indexWhere((it) => it.id == messageId); - if (anchorIndex < 0) { - throw StreamChatError( - 'Cannot mark as unread: Message "$messageId" was not found in the ' - 'locally-known messages for this channel.', - ); - } - - final anchor = messages[anchorIndex]; - final previous = anchorIndex > 0 ? messages[anchorIndex - 1] : null; - - // Subtract a microsecond so the anchor message itself is treated as - // "after" the new read boundary, matching the "from the provided - // message onwards" semantics described above. Preferred over using - // `previous.createdAt` as the boundary, which would leak the anchor - // back into the read set if the two share an identical `createdAt`. - final lastRead = anchor.createdAt.subtract(const Duration(microseconds: 1)); - state!.markUnreadLocally(lastRead: lastRead, lastReadMessageId: previous?.id); - return EmptyResponse(); - } - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark as unread: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markChannelUnread(id!, type, messageId); - } - - /// Marks the channel as unread by a given [timestamp]. - /// - /// All messages after the provided timestamp will be marked as unread. This - /// boundary is **exclusive**: a message created at exactly [timestamp] stays - /// read. Contrast with [markUnread], which is inclusive of the message it is - /// given — `markUnread(m.id)` is equivalent to - /// `markUnreadByTimestamp(m.createdAt - 1µs)`, not to - /// `markUnreadByTimestamp(m.createdAt)`. - /// - /// If [usesLocalUnreadCount] is `true` for this channel, this updates the - /// unread count locally, on-device, without making a network request. - Future markUnreadByTimestamp(DateTime timestamp) async { - _checkInitialized(); - - if (usesLocalUnreadCount) { - // The newest locally-known message at or before the boundary is the last - // one that stays read. - final lastReadMessage = state!.messages.lastWhereOrNull( - (it) => !it.createdAt.isAfter(timestamp), - ); - - state!.markUnreadLocally( - lastRead: timestamp, - lastReadMessageId: lastReadMessage?.id, - ); - return EmptyResponse(); - } - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark as unread: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markChannelUnreadByTimestamp(id!, type, timestamp); - } - - /// Mark the thread with [threadId] in the channel as read. - Future markThreadRead(String threadId) async { - _checkInitialized(); - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark thread as read: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markThreadRead(id!, type, threadId); - } - - /// Mark the thread with [threadId] in the channel as unread. - Future markThreadUnread(String threadId) async { - _checkInitialized(); - - if (!canUseReadReceipts) { - throw const StreamChatError( - 'Cannot mark thread as unread: Channel does not support read events. ' - 'Enable read_events in your channel type configuration.', - ); - } - - return _client.markThreadUnread(id!, type, threadId); - } - - void _initState(ChannelState channelState) { - state = ChannelClientState(this, channelState); - _initializedCompleter.safeComplete(true); - - if (cid case final cid?) client.state.addChannels({cid: this}); - _client.logger.info('Channel ${channelState.channel?.cid} initialized'); - } - - /// Loads the initial channel state and watches for changes. - Future watch({ - bool presence = false, - PaginationParams? messagesPagination, - PaginationParams? membersPagination, - PaginationParams? watchersPagination, - }) { - return query( - watch: true, - presence: presence, - messagesPagination: messagesPagination, - membersPagination: membersPagination, - watchersPagination: watchersPagination, - ); - } - - /// Stop watching the channel. - Future stopWatching() async { - _checkInitialized(); - return _client.stopChannelWatching(id!, type); - } - - /// List the message replies for a parent message. - /// - /// Set [preferOffline] to true to avoid the api call if the data is already - /// in the offline storage. - Future getReplies( - String parentId, { - PaginationParams? options, - bool preferOffline = false, - }) async { - QueryRepliesResponse? response; - - // If we prefer offline, we first try to get the replies from the - // offline storage. - if (preferOffline) { - if (_client.chatPersistenceClient case final persistenceClient?) { - final cachedReplies = await persistenceClient.getReplies( - parentId, - options: options, - ); - - // If the cached replies are not empty, we can use them. - if (cachedReplies.isNotEmpty) { - response = QueryRepliesResponse()..messages = cachedReplies; - } - } - } - - // If we still don't have the replies, we try to get them from the API. - response ??= await _client.getReplies(parentId, options: options); - - // Before updating the state, we check if we are querying around a - // reply, If we are, we have to clear the state to avoid potential - // gaps in the message sequence. - final isQueryingAround = switch (options) { - PaginationParams(idAround: _?) => true, - PaginationParams(createdAtAround: _?) => true, - _ => false, - }; - - if (isQueryingAround) state?.clearThread(parentId); - state?.updateThreadInfo(parentId, response.messages); - - return response; - } - - /// List the reactions for a message in the channel. - Future getReactions( - String messageId, { - PaginationParams? pagination, - }) => _client.getReactions( - messageId, - pagination: pagination, - ); - - /// Retrieves a list of messages by given [messageIDs]. - Future getMessagesById( - List messageIDs, - ) async { - _checkInitialized(); - return _client.getMessagesById(id!, type, messageIDs); - } - - /// Translate a message by given [messageId] and [language]. - /// - /// The translated message is merged into the channel state, so its - /// translations are available to everything listening to it without the - /// caller having to apply the response itself. - Future translateMessage( - String messageId, - String language, - ) async { - final response = await _client.translateMessage(messageId, language); - state?.updateMessage(response.message); - return response; - } - - /// Creates a new channel. - Future create() => query(state: false); - - /// Query the API, get messages, members or other channel fields. - /// - /// Set [preferOffline] to true to avoid the API call if the data is already - /// in the offline storage. - Future query({ - bool state = true, - bool watch = false, - bool presence = false, - PaginationParams? messagesPagination, - PaginationParams? membersPagination, - PaginationParams? watchersPagination, - bool preferOffline = false, - }) async { - // A prior failed init left the completer errored; reset it so this attempt - // owns the `initialized` result. Must stay before the first `await` so a - // caller reading `initialized` right after `query()`/`watch()` begins sees - // the fresh completer. - if (_initializedCompleter.isCompleted && this.state == null) { - _initializedCompleter = Completer(); - } - - ChannelState? channelState; - - try { - // If we prefer offline, we first try to get the channel state from the - // offline storage. - if (preferOffline && !watch && cid != null) { - final persistenceClient = _client.chatPersistenceClient; - if (persistenceClient != null) { - final cachedState = await persistenceClient.getChannelStateByCid( - cid!, - messagePagination: messagesPagination, - ); - - // If the cached state contains messages, we can use it. - if (cachedState.messages?.isNotEmpty == true) { - channelState = cachedState; - } - } - } - - // If we still don't have the channelState, we try to get it from the API. - channelState ??= await _client.queryChannel( - type, - channelId: id, - channelData: _extraData, - state: state, - watch: watch, - presence: presence, - messagesPagination: messagesPagination, - membersPagination: membersPagination, - watchersPagination: watchersPagination, - ); - - if (_id == null) { - _id = channelState.channel!.id; - _cid = channelState.channel!.cid; - } - - // Initialize the channel state if it's not initialized yet. - if (this.state == null) { - _initState(channelState); - } else { - // Otherwise, we update the existing state with the new channel state. - // - // But, before updating the state, we check if we are querying around a - // message, If we are, we have to truncate the state to avoid potential - // gaps in the message sequence. - final isQueryingAround = switch (messagesPagination) { - PaginationParams(idAround: _?) => true, - PaginationParams(createdAtAround: _?) => true, - _ => false, - }; - - if (isQueryingAround) this.state?.truncate(); - this.state?.updateChannelStateFromServer(channelState); - } - - // Submit for delivery reporting only when fetching the latest messages. - // This happens when no pagination params are provided (initial query). - if (messagesPagination == null) { - _client.channelDeliveryReporter.submitForDelivery([this]); - } - - return channelState; - } catch (e, stk) { - // If we failed to get the channel state from the API and we were not - // supposed to watch the channel, we will try to get the channel state - // from the offline storage. - if (watch == false) { - if (_client.persistenceEnabled) { - return _client.chatPersistenceClient!.getChannelStateByCid( - cid!, - messagePagination: messagesPagination, - ); - } - } - - // Otherwise, we will just rethrow the error. - _initializedCompleter.safeCompleteError(e, stk); - - rethrow; - } - } - - /// Query channel members. - Future queryMembers({ - Filter? filter, - SortOrder? sort, - PaginationParams? pagination, - }) => _client.queryMembers( - type, - channelId: id, - filter: filter, - members: state?.members, - sort: sort, - pagination: pagination, - ); - - /// Query channel banned users. - Future queryBannedUsers({ - Filter? filter, - SortOrder? sort, - PaginationParams? pagination, - }) { - _checkInitialized(); - filter ??= Filter.equal('channel_cid', cid!); - return _client.queryBannedUsers( - filter: filter, - sort: sort, - pagination: pagination, - ); - } - - // Timer to keep track of mute expiration. This is used to update the channel - // state when the mute expires. - Timer? _muteExpirationTimer; - - /// Mutes the channel. - Future mute({Duration? expiration}) { - _checkInitialized(); - - // If there is a expiration set, we will set a timer to automatically unmute - // the channel when the mute expires. - if (expiration != null) { - _muteExpirationTimer?.cancel(); - _muteExpirationTimer = Timer(expiration, unmute); - } - - return _client.muteChannel(cid!, expiration: expiration); - } - - /// Unmute the channel. - Future unmute() { - _checkInitialized(); - - // Cancel the mute expiration timer if it is set. - _muteExpirationTimer?.cancel(); - _muteExpirationTimer = null; - - return _client.unmuteChannel(cid!); - } - - /// Bans the member with given [userID] from the channel. - Future banMember( - String userID, - Map options, - ) async { - _checkInitialized(); - final opts = Map.from(options) - ..addAll({ - 'type': type, - 'id': id, - }); - return _client.banUser(userID, opts); - } - - /// Remove the ban for the member with given [userID] in the channel. - Future unbanMember(String userID) async { - _checkInitialized(); - return _client.unbanUser(userID, { - 'type': type, - 'id': id, - }); - } - - /// Shadow bans the user with the given [userID] from the channel. - Future shadowBan( - String userID, - Map options, - ) async { - _checkInitialized(); - final opts = Map.from(options) - ..addAll({ - 'type': type, - 'id': id, - }); - return _client.shadowBan(userID, opts); - } - - /// Remove the shadow ban for the user with the given [userID] in the channel. - Future removeShadowBan(String userID) async { - _checkInitialized(); - return _client.removeShadowBan(userID, { - 'type': type, - 'id': id, - }); - } - - /// Hides the channel from [StreamChatClient.queryChannels] for the user - /// until a message is added. - /// - /// If [clearHistory] is set to true - all messages - /// will be removed for the user. - Future hide({bool clearHistory = false}) async { - _checkInitialized(); - return _client.hideChannel( - id!, - type, - clearHistory: clearHistory, - ); - } - - /// Removes the hidden status for the channel. - Future show() async { - _checkInitialized(); - return _client.showChannel(id!, type); - } - - /// Pins the channel for the current user. - Future pin() async { - _checkInitialized(); - - final response = await _client.pinChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Unpins the channel. - Future unpin() async { - _checkInitialized(); - - final response = await _client.unpinChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Archives the channel. - Future archive() async { - _checkInitialized(); - - final response = await _client.archiveChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Unarchives the channel for the current user. - Future unarchive() async { - _checkInitialized(); - - final response = await _client.unarchiveChannel( - channelId: id!, - channelType: type, - ); - - return response.channelMember; - } - - /// Stream of [Event] coming from websocket connection specific for the - /// channel. Pass an eventType as parameter in order to filter just a type - /// of event. - Stream on([ - String? eventType, - String? eventType2, - String? eventType3, - String? eventType4, - ]) => _client - .on( - eventType, - eventType2, - eventType3, - eventType4, - ) - .where((e) => e.cid == cid); - - late final _keyStrokeHandler = KeyStrokeHandler( - onStartTyping: startTyping, - onStopTyping: stopTyping, - ); - - // Whether sending typing events is allowed in the channel and by the user - // privacy settings. - bool get _canSendTypingEvents { - final currentUser = client.state.currentUser; - if (currentUser == null) return false; - - return canUseTypingEvents && currentUser.isTypingIndicatorsEnabled; - } - - /// Sends the [Event.typingStart] event and schedules a timer to invoke the - /// [Event.typingStop] event. - /// - /// This is meant to be called every time the user presses a key. - Future keyStroke([String? parentId]) async { - if (!_canSendTypingEvents) return; - - client.logger.info('KeyStroke received'); - return _keyStrokeHandler(parentId); - } - - /// Sends the [EventType.typingStart] event. - Future startTyping([String? parentId]) async { - if (!_canSendTypingEvents) return; - - client.logger.info('start typing'); - await sendEvent( - Event( - type: EventType.typingStart, - parentId: parentId, - ), - ); - } - - /// Sends the [EventType.typingStop] event. - Future stopTyping([String? parentId]) async { - if (!_canSendTypingEvents) return; - - client.logger.info('stop typing'); - await sendEvent( - Event( - type: EventType.typingStop, - parentId: parentId, - ), - ); - } - - /// Call this method to dispose the channel client. - void dispose() { - client.state.removeChannel('$cid'); - state?.dispose(); - state = null; - _muteExpirationTimer?.cancel(); - _keyStrokeHandler.cancel(); - } - - void _checkInitialized() { - if (_isInitialized) return; - - throw StateError( - "Channel $cid hasn't been initialized yet or has been disposed. " - 'Make sure to call .watch() or instantiate the client using ' - '[Channel.fromState]', - ); - } -} - -/// The class that handles the state of the channel listening to the events. -class ChannelClientState { - /// Creates a new instance listening to events and updating the state. - ChannelClientState( - this._channel, - ChannelState channelState, - ) { - _retryQueue = RetryQueue( - channel: _channel, - logger: _client.detachedLogger( - '🔄 (${generateHash([_channel.cid])})', - ), - ); - - _channelStateController = BehaviorSubject.seeded(channelState); - // Update the persistence storage with the seeded channel state. - _debouncedUpdatePersistenceChannelState.call([channelState]); - - // region TYPING EVENTS - _listenTypingEvents(); - // endregion - - // region MESSAGE EVENTS - _listenMessageNew(); - _listenMessageDeleted(); - _listenMessageUpdated(); - // endregion - - // region DRAFT EVENTS - _listenDraftUpdated(); - _listenDraftDeleted(); - // endregion - - // region REACTION EVENTS - _listenReactionNew(); - _listenReactionUpdated(); - _listenReactionDeleted(); - // endregion - - // region POLL EVENTS - _listenPollCreated(); - _listenPollUpdated(); - _listenPollClosed(); - _listenPollAnswerCasted(); - _listenPollVoteCasted(); - _listenPollVoteChanged(); - _listenPollAnswerRemoved(); - _listenPollVoteRemoved(); - // endregion - - // region READ EVENTS - _listenReadEvents(); - // endregion - - // region CHANNEL EVENTS - _listenChannelTruncated(); - _listenChannelUpdated(); - _listenChannelCounts(); - // endregion - - // region MEMBER EVENTS - _listenMemberAdded(); - _listenMemberRemoved(); - _listenMemberUpdated(); - _listenMemberBanned(); - _listenMemberUnbanned(); - _listenUserMessagesDeleted(); - // endregion - - // region USER WATCHING EVENTS - _listenUserStartWatching(); - _listenUserStopWatching(); - // endregion - - // region REMINDER EVENTS - _listenReminderCreated(); - _listenReminderUpdated(); - _listenReminderDeleted(); - // endregion - - // region LOCATION EVENTS - _listenLocationShared(); - _listenLocationUpdated(); - _listenLocationExpired(); - // endregion - - _startCleaningStaleTypingEvents(); - - _startCleaningStalePinnedMessages(); - - _startCleaningExpiredLocations(); - - _listenChannelPushPreferenceUpdated(); - - final persistenceClient = _client.chatPersistenceClient; - persistenceClient - ?.getChannelThreads(_channel.cid!) - .then((threads) { - // Load all the threads for the channel from the offline storage. - if (threads.isNotEmpty) _threads = threads; - }) - .then((_) => retryFailedMessages()); - } - - final Channel _channel; - StreamChatClient get _client => _channel._client; - final _subscriptions = CompositeSubscription(); - - void _listenMemberAdded() { - _subscriptions.add( - _channel.on(EventType.memberAdded).listen((Event e) { - final member = e.member!; - final existingMembers = channelState.members ?? []; - - updateChannelState( - channelState.copyWith( - members: [...existingMembers, member], - ), - ); - }), - ); - } - - void _listenMemberRemoved() { - _subscriptions.add( - _channel.on(EventType.memberRemoved).listen((Event e) { - final user = e.user!; - final existingRead = channelState.read ?? []; - final existingMembers = channelState.members ?? []; - - updateChannelState( - channelState.copyWith( - read: [...existingRead.where((r) => r.user.id != user.id)], - members: [...existingMembers.where((m) => m.userId != user.id)], - ), - ); - }), - ); - } - - void _listenMemberUpdated() { - _subscriptions - // Listen to events containing member users - ..add( - _channel.on().listen( - (event) { - final user = event.user; - if (user == null) return; - - final existingMembers = [...?channelState.members]; - final existingMembership = channelState.membership; - - // Return if the user is not a existing member of the channel. - if (!existingMembers.any((m) => m.userId == user.id)) return; - - Member? maybeUpdateMemberUser(Member? existingMember) { - if (existingMember == null) return null; - if (existingMember.userId == user.id) { - return existingMember.copyWith(user: user); - } - return existingMember; - } - - updateChannelState( - channelState.copyWith( - membership: maybeUpdateMemberUser(existingMembership), - members: [...existingMembers.map(maybeUpdateMemberUser).nonNulls], - ), - ); - }, - ), - ) - // Listen to member updated events. - ..add( - _channel.on(EventType.memberUpdated).listen( - (Event e) { - final member = e.member!; - final existingMembers = channelState.members ?? []; - final existingMembership = channelState.membership; - - Member? maybeUpdateMember(Member? existingMember) { - if (existingMember == null) return null; - if (existingMember.userId == member.userId) return member; - return existingMember; - } - - updateChannelState( - channelState.copyWith( - membership: maybeUpdateMember(existingMembership), - members: [...existingMembers.map(maybeUpdateMember).nonNulls], - ), - ); - }, - ), - ); - } - - void _listenChannelUpdated() { - _subscriptions.add( - _channel.on(EventType.channelUpdated).listen((Event e) { - final channel = e.channel!; - updateChannelState( - channelState.copyWith( - channel: channelState.channel?.merge(channel), - members: channel.members, - ), - ); - }), - ); - } - - // Most channel events carry the channel's member and message counts as - // event metadata, reflecting the authoritative values after the change. - // Applying them keeps the counts fresh for the whole session instead of - // only right after a `query` / `watch`. - void _listenChannelCounts() { - _subscriptions.add( - _channel.on().listen( - (Event e) { - final memberCount = e.channelMemberCount; - final messageCount = e.channelMessageCount; - if (memberCount == null && messageCount == null) return; - - updateChannelState( - channelState.copyWith( - channel: channelState.channel?.copyWith( - memberCount: memberCount, - messageCount: messageCount, - ), - ), - ); - }, - ), - ); - } - - void _listenChannelTruncated() { - _subscriptions.add( - _channel.on(EventType.channelTruncated, EventType.notificationChannelTruncated).listen((event) async { - final channel = event.channel!; - await _client.chatPersistenceClient?.deleteMessageByCid(channel.cid); - truncate(); - if (event.message != null) { - updateMessage(event.message!); - } - }), - ); - } - - void _listenMemberBanned() { - _subscriptions.add( - _channel - .on(EventType.userBanned) - .where((it) => it.cid != null) // filters channel ban from app ban - .listen( - (event) async { - final user = event.user!; - final member = await _channel - .queryMembers(filter: Filter.equal('id', user.id)) - .then((it) => it.members.first); - - _updateMember(member); - }, - ), - ); - } - - void _listenUserStartWatching() { - _subscriptions.add( - _channel.on(EventType.userWatchingStart).listen((event) { - final watcher = event.user; - if (watcher != null) { - final existingWatchers = channelState.watchers; - updateChannelState( - channelState.copyWith( - watchers: [ - watcher, - ...?existingWatchers?.where((user) => user.id != watcher.id), - ], - watcherCount: event.watcherCount, - ), - ); - } - }), - ); - } - - void _listenUserStopWatching() { - _subscriptions.add( - _channel.on(EventType.userWatchingStop).listen((event) { - final watcher = event.user; - if (watcher != null) { - final existingWatchers = channelState.watchers ?? const []; - _channelState = channelState.copyWith( - watchers: existingWatchers.where((user) => user.id != watcher.id).toList(), - watcherCount: event.watcherCount, - ); - } - }), - ); - } - - void _listenMemberUnbanned() { - _subscriptions.add( - _channel - .on(EventType.userUnbanned) - .where((it) => it.cid != null) // filters channel ban from app ban - .listen( - (event) async { - final user = event.user!; - final member = await _channel - .queryMembers(filter: Filter.equal('id', user.id)) - .then((it) => it.members.first); - - _updateMember(member); - }, - ), - ); - } - - void _updateMember(Member member) { - final currentMembers = [...members]; - final memberIndex = currentMembers.indexWhere( - (m) => m.userId == member.userId, - ); - - if (memberIndex == -1) return; - currentMembers[memberIndex] = member; - - updateChannelState( - channelState.copyWith( - members: currentMembers, - ), - ); - } - - /// Flag which indicates if [ChannelClientState] contain latest/recent messages or not. - /// - /// This flag should be managed by UI sdks. - /// - /// When false, any new message received by WebSocket event - /// [EventType.messageNew] will not be pushed on to message list. - bool get isUpToDate => _isUpToDateController.value; - - set isUpToDate(bool isUpToDate) => _isUpToDateController.safeAdd(isUpToDate); - - /// [isUpToDate] flag count as a stream. - Stream get isUpToDateStream => _isUpToDateController.stream; - final _isUpToDateController = BehaviorSubject.seeded(true); - - /// The retry queue associated to this channel. - late final RetryQueue _retryQueue; - - /// Retry failed message. - Future retryFailedMessages() async { - final allMessages = [...messages, ...threads.values.flattened]; - final failedMessages = allMessages.where((it) => it.state.isFailed); - - if (failedMessages.isEmpty) return; - _retryQueue.add(failedMessages); - } - - Message? _findPollMessage(String pollId) { - final message = messages.firstWhereOrNull((it) => it.pollId == pollId); - if (message != null) return message; - - final threadMessage = threads.values.flattened.firstWhereOrNull((it) { - return it.pollId == pollId; - }); - - return threadMessage; - } - - void _listenPollCreated() { - _subscriptions.add( - _channel.on(EventType.pollCreated).listen((event) { - final message = event.message; - if (message == null || message.poll == null) return; - - return addNewMessage(message); - }), - ); - } - - void _listenPollUpdated() { - _subscriptions.add( - _channel.on(EventType.pollUpdated).listen((event) { - final eventPoll = event.poll; - if (eventPoll == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final ownVotesAndAnswers = oldPoll?.ownVotesAndAnswers ?? eventPoll.ownVotesAndAnswers; - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollClosed() { - _subscriptions.add( - _channel.on(EventType.pollClosed).listen((event) { - final eventPoll = event.poll; - if (eventPoll == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - final poll = oldPoll?.copyWith(isClosed: true) ?? eventPoll; - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollAnswerCasted() { - _subscriptions.add( - _channel.on(EventType.pollAnswerCasted).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = { - for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, - eventPollVote.id!: eventPollVote, - }; - - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, - }; - - final poll = eventPoll.copyWith( - latestAnswers: [...latestAnswers.values], - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollVoteCasted() { - _subscriptions.add( - _channel.on(EventType.pollVoteCasted).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, - }; - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollAnswerRemoved() { - _subscriptions.add( - _channel.on(EventType.pollAnswerRemoved).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = { - for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, - }..remove(eventPollVote.id); - - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - }..remove(eventPollVote.id); - - final poll = eventPoll.copyWith( - latestAnswers: [...latestAnswers.values], - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollVoteRemoved() { - _subscriptions.add( - _channel.on(EventType.pollVoteRemoved).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - }..remove(eventPollVote.id); - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenPollVoteChanged() { - _subscriptions.add( - _channel.on(EventType.pollVoteChanged).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; - - final oldPoll = pollMessage.poll; - - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, - }; - - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); - - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - }), - ); - } - - void _listenDraftUpdated() { - _subscriptions.add( - _channel.on(EventType.draftUpdated).listen((event) { - final draft = event.draft; - if (draft == null) return; - - return updateDraft(draft); - }), - ); - } - - void _listenDraftDeleted() { - _subscriptions.add( - _channel.on(EventType.draftDeleted).listen((event) { - final draft = event.draft; - if (draft == null) return; - - return deleteDraft(draft); - }), - ); - } - - void _listenReminderCreated() { - _subscriptions.add( - _channel.on(EventType.reminderCreated).listen((event) { - final reminder = event.reminder; - if (reminder == null) return; - - updateReminder(reminder); - }), - ); - } - - void _listenReminderUpdated() { - _subscriptions.add( - _channel.on(EventType.reminderUpdated).listen((event) { - final reminder = event.reminder; - if (reminder == null) return; - - updateReminder(reminder); - }), - ); - } - - void _listenReminderDeleted() { - _subscriptions.add( - _channel.on(EventType.reminderDeleted).listen((event) { - final reminder = event.reminder; - if (reminder == null) return; - - deleteReminder(reminder); - }), - ); - } - - /// Updates the [reminder] of the message if it exists. - void updateReminder(MessageReminder reminder) { - final messageId = reminder.messageId; - // TODO: Improve once we have support for parentId in reminders. - for (final message in [...messages, ...threads.values.flattened]) { - if (message.id == messageId) { - return updateMessage( - message.copyWith(reminder: reminder), - ); - } - } - } - - /// Deletes the [reminder] of the message if it exists. - void deleteReminder(MessageReminder reminder) { - final messageId = reminder.messageId; - // TODO: Improve once we have support for parentId in reminders. - for (final message in [...messages, ...threads.values.flattened]) { - if (message.id == messageId) { - return updateMessage( - message.copyWith(reminder: null), - ); - } - } - } - - Message? _findLocationMessage(String id) { - final message = messages.firstWhereOrNull((it) { - return it.sharedLocation?.messageId == id; - }); - - if (message != null) return message; - - final threadMessage = threads.values.flattened.firstWhereOrNull((it) { - return it.sharedLocation?.messageId == id; - }); - - return threadMessage; - } - - void _listenLocationShared() { - _subscriptions.add( - _channel.on(EventType.locationShared).listen((event) { - final message = event.message; - if (message == null || message.sharedLocation == null) return; - - return addNewMessage(message); - }), - ); - } - - void _listenLocationUpdated() { - _subscriptions.add( - _channel.on(EventType.locationUpdated).listen((event) { - final location = event.message?.sharedLocation; - if (location == null) return; - - final messageId = location.messageId; - if (messageId == null) return; - - final oldMessage = _findLocationMessage(messageId); - if (oldMessage == null) return; - - final updatedMessage = oldMessage.copyWith(sharedLocation: location); - return updateMessage(updatedMessage); - }), - ); - } - - void _listenLocationExpired() { - _subscriptions.add( - _channel.on(EventType.locationExpired).listen((event) { - final location = event.message?.sharedLocation; - if (location == null) return; - - final messageId = location.messageId; - if (messageId == null) return; - - final oldMessage = _findLocationMessage(messageId); - if (oldMessage == null) return; - - final updatedMessage = oldMessage.copyWith(sharedLocation: location); - return updateMessage(updatedMessage); - }), - ); - } - - void _listenReactionDeleted() { - _subscriptions.add( - _channel.on(EventType.reactionDeleted).listen((event) { - final (eventReaction, eventMessage) = (event.reaction, event.message); - if (eventReaction == null || eventMessage == null) return; - - final messageId = eventMessage.id; - final parentId = eventMessage.parentId; - - for (final message in [...messages, ...?threads[parentId]]) { - if (message.id == messageId) { - final currentUserId = _channel.client.state.currentUser?.id; - - final currentMessage = switch (currentUserId) { - final userId? when userId == eventReaction.userId => message.deleteMyReaction( - reactionType: eventReaction.type, - ), - _ => message, - }; - - return updateMessage( - eventMessage.copyWith( - ownReactions: currentMessage.ownReactions, - ), - ); - } - } - }), - ); - } - - void _listenReactionNew() { - _subscriptions.add( - _channel.on(EventType.reactionNew).listen((event) { - final (eventReaction, eventMessage) = (event.reaction, event.message); - if (eventReaction == null || eventMessage == null) return; - - final messageId = eventMessage.id; - final parentId = eventMessage.parentId; - - for (final message in [...messages, ...?threads[parentId]]) { - if (message.id == messageId) { - final currentUserId = _channel.client.state.currentUser?.id; - - final currentMessage = switch (currentUserId) { - final userId? when userId == eventReaction.userId => message.addMyReaction(eventReaction), - _ => message, - }; - - return updateMessage( - eventMessage.copyWith( - ownReactions: currentMessage.ownReactions, - ), - ); - } - } - }), - ); - } - - void _listenReactionUpdated() { - _subscriptions.add( - _channel.on(EventType.reactionUpdated).listen((event) { - final (eventReaction, eventMessage) = (event.reaction, event.message); - if (eventReaction == null || eventMessage == null) return; - - final messageId = eventMessage.id; - final parentId = eventMessage.parentId; - - for (final message in [...messages, ...?threads[parentId]]) { - if (message.id == messageId) { - final currentUserId = _channel.client.state.currentUser?.id; - - final currentMessage = switch (currentUserId) { - final userId? when userId == eventReaction.userId => - // reaction.updated is only called if enforce_unique is true - message.addMyReaction(eventReaction, enforceUnique: true), - _ => message, - }; - - return updateMessage( - eventMessage.copyWith( - ownReactions: currentMessage.ownReactions, - ), - ); - } - } - }), - ); - } - - void _listenMessageUpdated() { - _subscriptions.add( - _channel.on(EventType.messageUpdated).listen((event) { - final message = event.message; - if (message == null) return; - - return updateMessage(message, upsert: false); - }), - ); - } - - void _listenMessageDeleted() { - _subscriptions.add( - _channel.on(EventType.messageDeleted).listen((event) { - final hardDelete = event.hardDelete ?? false; - - final message = event.message!.copyWith( - // TODO: Remove once deletedForMe is properly enriched on the backend. - deletedForMe: event.deletedForMe, - ); - - // Decrement the locally-tracked unread count for hard-deleted - // messages that would have counted as unread. Soft-deleted messages - // keep their slot. Only applies to channels that track unread counts - // locally (see [Channel.usesLocalUnreadCount]) — server-driven - // channels get corrected counts from server read events instead. - if (hardDelete && _channel.usesLocalUnreadCount && MessageRules.canCountAsUnread(message, _channel)) { - unreadCount = math.max(0, unreadCount - 1); - } - - return deleteMessage(message, hardDelete: hardDelete); - }), - ); - } - - void _listenMessageNew() { - _subscriptions.add( - _channel - .on( - EventType.messageNew, - EventType.notificationMessageNew, - ) - .listen((event) { - final message = event.message; - if (message == null) return; - - addNewMessage(message); - - // Only message.new carries a reliable watcher count; - // notification.message_new targets non-watchers and reports 0. - if (event.watcherCount case final watcherCount? when event.type == EventType.messageNew) { - updateChannelState( - channelState.copyWith(watcherCount: watcherCount), - ); - } - }), - ); - } - - /// Adds a new message to the channel state and updates the unread count. - void addNewMessage(Message message) { - final isThreadMessage = message.parentId != null; - final isNotShownInChannel = message.showInChannel != true; - final isThreadOnlyMessage = isThreadMessage && isNotShownInChannel; - - // Only add the message if the channel is upToDate or if the message is - // a thread-only message. - if (isUpToDate || isThreadOnlyMessage) updateMessage(message); - - // Otherwise, check if we can count the message as unread. - if (MessageRules.canCountAsUnread(message, _channel)) { - unreadCount += 1; // Increment unread count - } - - _client.channelDeliveryReporter.submitForDelivery([_channel]); - } - - /// Updates the [read] in the state if it exists. Adds it otherwise. - void updateRead([Iterable? read]) { - final existingReads = channelState.read ?? const []; - final updatedReads = existingReads.merge( - read, - key: (read) => read.user.id, - ); - - updateChannelState( - channelState.copyWith( - read: updatedReads.toList(), - ), - ); - } - - /// Updates the [draft] in the channel state or the message if it exists. - void updateDraft(Draft draft) { - if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage(message.copyWith(draft: draft)); - } - } - } - - updateChannelState( - channelState.copyWith( - draft: draft, - ), - ); - } - - /// Deletes the [draft] from the state if it exists. - void deleteDraft(Draft draft) async { - // Delete the draft from the persistence client. - await _client.chatPersistenceClient?.deleteDraftMessageByCid( - draft.channelCid, - parentId: draft.parentId, - ); - - if (draft.parentId case final parentId?) { - for (final message in messages) { - if (message.id == parentId) { - return updateMessage( - message.copyWith(draft: null), - ); - } - } - } - - updateChannelState( - channelState.copyWith( - draft: null, - ), - ); - } - - /// Updates the [message] in the state. - /// - /// Reconciles via `Message.updateWith`, so locally-known enrichment - /// (poll, sharedLocation, ownReactions, nested quotedMessage) is - /// preserved when [message] omits those fields. Use [replaceMessage] - /// for paths that need a strict overwrite. - /// - /// When [upsert] is `true` (the default) and [message] isn't already in - /// the state, it's added. When `false`, an unknown [message] is skipped - /// and the state is left unchanged; only a message already loaded in the - /// state is updated. - void updateMessage(Message message, {bool upsert = true}) => _updateMessages([message], upsert: upsert); - - /// Replaces the [message] in the state if it exists, no-op otherwise. - /// - /// Unlike [updateMessage], this does **not** merge with the existing - /// state — [message] is used as-is. Useful for local rollbacks of an - /// optimistic update, where the caller has the full prior snapshot and - /// doesn't want the merge falling back to the optimistic values. - void replaceMessage(Message message) => _updateMessages([message], update: _replaceUpdate); - - // Default `update` for [_updateMessages]: merge incoming with the - // locally-known message via `Message.updateWith`, preserving enrichment - // the server may strip on partial payloads. - static Message _mergeUpdate(Message original, Message updated) => original.updateWith(updated); - - // Replace `update` for [_updateMessages]: take the incoming as-is. Used - // by local rollback paths. - static Message _replaceUpdate(Message _, Message updated) => updated; - - /// Cleans up all the stale error messages which requires no action. - void cleanUpStaleErrorMessages() { - final errorMessages = messages.where((message) { - return message.isError && !message.isBounced; - }); - - if (errorMessages.isEmpty) return; - return _removeMessages(errorMessages); - } - - /// Remove a [message] from this [channelState]. - void removeMessage(Message message) => _removeMessages([message]); - - /// Removes/Updates the [message] based on the [hardDelete] value. - void deleteMessage(Message message, {bool hardDelete = false}) { - return _deleteMessages([message], hardDelete: hardDelete); - } - - void _listenReadEvents() { - _subscriptions - ..add( - _channel.on(EventType.messageRead, EventType.notificationMarkRead).listen( - (event) { - // Skip handling the event if delivered for a thread - if (event.thread != null) return; - - final user = event.user; - if (user == null) return; - - final currentRead = userReadOf(userId: user.id); - - final updatedRead = Read( - user: user, - lastRead: event.createdAt, - unreadMessages: 0, // Reset unread count - lastReadMessageId: event.lastReadMessageId, - // Preserve delivery info as it's not part of the read event. - lastDeliveredAt: currentRead?.lastDeliveredAt, - lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, - ); - - updateRead([updatedRead]); - - // If the read event is from the current user, reconcile the - // channel delivery status with the updated read state. - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) { - _client.channelDeliveryReporter.reconcileDelivery([_channel]); - } - }, - ), - ) - ..add( - _channel.on(EventType.notificationMarkUnread).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentRead = userReadOf(userId: user.id); - - final updatedRead = Read( - user: user, - lastRead: event.lastReadAt!, - unreadMessages: event.unreadMessages, - lastReadMessageId: event.lastReadMessageId, - // Preserve delivery info as it's not part of the read event. - lastDeliveredAt: currentRead?.lastDeliveredAt, - lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, - ); - - return updateRead([updatedRead]); - }, - ), - ) - ..add( - _channel.on(EventType.messageDelivered).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentRead = userReadOf(userId: user.id); - final never = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - final updatedRead = Read( - user: user, - lastDeliveredAt: event.lastDeliveredAt, - lastDeliveredMessageId: event.lastDeliveredMessageId, - // Preserve read info as it's not part of the delivery event. - lastRead: currentRead?.lastRead ?? never, - unreadMessages: currentRead?.unreadMessages, - lastReadMessageId: currentRead?.lastReadMessageId, - ); - - updateRead([updatedRead]); - - // If the delivered event is from the current user, reconcile - // the channel delivery with the updated read state. - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) { - _client.channelDeliveryReporter.reconcileDelivery([_channel]); - } - }, - ), - ); - } - - /// Channel message list. - List get messages => _channelState.messages ?? []; - - /// Channel message list as a stream. - Stream> get messagesStream => - channelStateStream.map((cs) => cs.messages ?? []).distinct(const ListEquality().equals); - - /// Channel pinned message list. - List get pinnedMessages => _channelState.pinnedMessages ?? []; - - /// Channel pinned message list as a stream. - Stream> get pinnedMessagesStream => - channelStateStream.map((cs) => cs.pinnedMessages ?? []).distinct(const ListEquality().equals); - - /// Channel pending message list. - List get pendingMessages => _channelState.pendingMessages ?? []; - - /// Channel pending message list as a stream. - Stream> get pendingMessagesStream => - channelStateStream.map((cs) => cs.pendingMessages ?? []).distinct(const ListEquality().equals); - - /// Get channel last message. - Message? get lastMessage => messages.lastOrNull; - - /// Get channel last message as a stream. - Stream get lastMessageStream { - return messagesStream.map((messages) => messages.lastOrNull); - } - - /// Channel members list. - List get members => - (_channelState.members ?? []).map((e) => e.copyWith(user: _client.state.users[e.user!.id])).toList(); - - /// Channel members list as a stream. - Stream> get membersStream => - CombineLatestStream.combine2?, Map, List>( - channelStateStream.map((cs) => cs.members), - _client.state.usersStream, - (members, users) => [...?members?.map((e) => e!.copyWith(user: users[e.user!.id]))], - ).distinct(const ListEquality().equals); - - /// Channel watcher count. - int? get watcherCount => _channelState.watcherCount; - - /// Channel watcher count as a stream. - Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount).distinct(); - - /// Channel watchers list. - List get watchers => (_channelState.watchers ?? []).map((e) => _client.state.users[e.id] ?? e).toList(); - - /// Channel watchers list as a stream. - Stream> get watchersStream => CombineLatestStream.combine2?, Map, List>( - channelStateStream.map((cs) => cs.watchers), - _client.state.usersStream, - (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], - ).distinct(const ListEquality().equals); - - /// Channel active live locations. - List get activeLiveLocations { - return _channelState.activeLiveLocations ?? []; - } - - /// Channel active live locations as a stream. - Stream> get activeLiveLocationsStream => - channelStateStream.map((cs) => cs.activeLiveLocations ?? []).distinct(const ListEquality().equals); - - /// Channel draft. - Draft? get draft => _channelState.draft; - - /// Channel draft as a stream. - Stream get draftStream { - return channelStateStream.map((cs) => cs.draft).distinct(); - } - - /// Channel member for the current user. - Member? get currentUserMember => members.firstWhereOrNull( - (m) => m.user?.id == _client.state.currentUser?.id, - ); - - /// Channel role for the current user - String? get currentUserChannelRole => currentUserMember?.channelRole; - - /// Channel read list. - List get read => _channelState.read ?? []; - - /// Channel read list as a stream. - Stream> get readStream => - channelStateStream.map((cs) => cs.read ?? []).distinct(const ListEquality().equals); - - /// Channel read for the logged in user. - Read? get currentUserRead { - final currentUser = _client.state.currentUser; - return userReadOf(userId: currentUser?.id); - } - - /// Channel read for the logged in user as a stream. - /// - /// Re-subscribes only when the user id actually changes; null still - /// propagates downstream so consumers see the logged-out transition. - Stream get currentUserReadStream { - final currentUserId = _client.state.currentUserStream.map((it) => it?.id).distinct(); - return currentUserId.switchMap((id) => userReadStreamOf(userId: id)).distinct(); - } - - /// Unread count getter as a stream. - Stream get unreadCountStream => currentUserReadStream.map((read) => read?.unreadMessages ?? 0).distinct(); - - /// Unread count getter. - int get unreadCount => currentUserRead?.unreadMessages ?? 0; - - /// Setter for unread count. - set unreadCount(int count) { - final currentUser = _client.state.currentUser; - if (currentUser == null) return; - - var existingUserRead = currentUserRead; - if (existingUserRead == null) { - final lastMessageAt = _channelState.channel?.lastMessageAt; - existingUserRead = Read( - user: currentUser, - lastRead: lastMessageAt ?? DateTime.now(), - ); - } - - return updateRead([existingUserRead.copyWith(unreadMessages: count)]); - } - - /// Marks the channel as read locally, without making a network request. - /// - /// Used for channels that track unread counts locally (see - /// [Channel.usesLocalUnreadCount]), since the server rejects the mark-read - /// endpoint for channels that have read events disabled. - /// - /// [messageId] only sets the resulting [Read.lastReadMessageId]; it does not - /// narrow which messages stay unread. The count always drops to zero and - /// [Read.lastRead] is always `now`, so messages newer than [messageId] are - /// marked read as well. This differs from the server, which recomputes the - /// count as the number of messages after [messageId], and from - /// [markUnreadLocally], which does recompute from the locally-known - /// messages. Callers that need a partial boundary should use - /// [markUnreadLocally] instead. - void markReadLocally({String? messageId}) { - final currentUser = _client.state.currentUser; - if (currentUser == null) return; - - final now = DateTime.now(); - final lastReadMessageId = messageId ?? messages.lastOrNull?.id; - - final existingUserRead = currentUserRead; - updateRead([ - Read( - user: currentUser, - lastRead: now, - lastReadMessageId: lastReadMessageId, - lastDeliveredAt: existingUserRead?.lastDeliveredAt, - lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId, - ), - ]); - - // Read supersedes delivered, so drop any pending delivery candidate the - // new read boundary just made ineligible. `delivery_events` is configured - // independently of `read_events`, so a channel tracking unread counts - // locally can still have delivery receipts enabled. Mirrors what the - // `message.read` event listener does for server-driven channels. - _client.channelDeliveryReporter.reconcileDelivery([_channel]); - } - - /// Marks the channel as unread locally, without making a network request. - /// - /// [lastRead] and [lastReadMessageId] define the new read boundary: any - /// locally-known message that is still eligible per - /// [MessageRules.canCountAsUnread] once this boundary is applied is counted - /// as unread. - /// - /// Used for channels that track unread counts locally (see - /// [Channel.usesLocalUnreadCount]), since the server rejects the - /// mark-unread endpoint for channels that have read events disabled. - void markUnreadLocally({ - required DateTime lastRead, - String? lastReadMessageId, - }) { - final currentUser = _client.state.currentUser; - if (currentUser == null) return; - - final existingUserRead = currentUserRead; - - // Apply the new read boundary first so `MessageRules.canCountAsUnread` - // (which reads `channel.state?.currentUserRead`) evaluates against it. - updateRead([ - Read( - user: currentUser, - lastRead: lastRead, - lastReadMessageId: lastReadMessageId, - lastDeliveredAt: existingUserRead?.lastDeliveredAt, - lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId, - ), - ]); - - // Recompute the unread count from the locally-known messages now that - // the boundary above is in effect. - final unread = messages.where((it) => MessageRules.canCountAsUnread(it, _channel)).length; - - unreadCount = unread; - } - - /// Counts the number of unread messages mentioning the current user. - /// - /// **NOTE**: The method relies on the [Channel.messages] list and doesn't do - /// any API call. Therefore, the count might be not reliable as it relies on - /// the local data. - int countUnreadMentions() { - final currentUserId = _client.state.currentUser?.id; - - var count = 0; - for (final message in messages) { - if (!MessageRules.canCountAsUnread(message, _channel)) continue; - if (!message.mentionedUsers.any((it) => it.id == currentUserId)) continue; - - count++; - } - - return count; - } - - /// Delete all channel messages. - void truncate() { - _channelState = _channelState.copyWith( - messages: [], - ); - } - - /// Drops the oldest messages, keeping at most [maxMessages]. - /// - /// No-op when [maxMessages] is non-positive, when the current count is - /// already within the limit, or when [isUpToDate] is `false`. - /// - /// Prefer `StreamChannel.pruneOldest` when a [StreamChannel] is present: - /// it also resets the widget-layer "top reached" marker so top-pagination - /// can resume. Calling this directly leaves that marker untouched. - void pruneOldest(int maxMessages) { - if (maxMessages <= 0) return; - if (!isUpToDate) return; - - final current = messages; - if (current.length <= maxMessages) return; - - final pruned = current.sublist(current.length - maxMessages); - _channelState = _channelState.copyWith(messages: pruned); - } - - /// Update channelState with updated information. - void updateChannelState(ChannelState updatedState) { - final newMessages = messages.mergeSorted( - updatedState.messages, - key: (message) => message.id, - update: _mergeUpdate, - compare: _sortByCreatedAt, - ); - - final watchers = _channelState.watchers ?? const []; - final newWatchers = watchers.merge( - updatedState.watchers, - key: (watcher) => watcher.id, - ); - - final reads = _channelState.read ?? const []; - final newReads = reads.merge( - updatedState.read, - key: (read) => read.user.id, - ); - - _channelState = _channelState.copyWith( - messages: newMessages, - channel: _channelState.channel?.merge(updatedState.channel), - watchers: newWatchers.toList(), - watcherCount: updatedState.watcherCount, - members: updatedState.members, - membership: updatedState.membership, - read: newReads.toList(), - draft: updatedState.draft, - pinnedMessages: updatedState.pinnedMessages, - pendingMessages: updatedState.pendingMessages, - pushPreferences: updatedState.pushPreferences, - activeLiveLocations: updatedState.activeLiveLocations, - ); - } - - /// Applies a [remoteState] received from the server or offline storage - /// (e.g. a `query`/`watch` response), merging it into local state. - /// - /// Unlike [updateChannelState], this preserves the current user's - /// locally-tracked read state for channels that track unread counts - /// on-device (see [Channel.usesLocalUnreadCount]) — their `lastRead`, - /// `lastReadMessageId`, and `unreadMessages` are kept as-is instead of - /// being overwritten by the remote payload; only delivery fields are - /// still applied from it. - /// - /// Call this instead of [updateChannelState] whenever [remoteState] - /// genuinely comes from the network or offline storage. - void updateChannelStateFromServer(ChannelState remoteState) { - updateChannelState(_preserveLocalUnreadState(remoteState)); - } - - /// Rewrites the current user's [Read] in [remoteState], if present, to - /// keep the locally-tracked `lastRead` / `lastReadMessageId` / - /// `unreadMessages` while still adopting the remote delivery fields. - /// - /// No-op unless [Channel.usesLocalUnreadCount] is enabled and a local read - /// already exists for the current user. - ChannelState _preserveLocalUnreadState(ChannelState remoteState) { - if (!_channel.usesLocalUnreadCount) return remoteState; - - final localRead = currentUserRead; - final remoteReads = remoteState.read; - if (localRead == null || remoteReads == null) return remoteState; - - final currentUserId = localRead.user.id; - final preservedReads = remoteReads.map((read) { - if (read.user.id != currentUserId) return read; - return localRead.copyWith( - lastDeliveredAt: read.lastDeliveredAt, - lastDeliveredMessageId: read.lastDeliveredMessageId, - ); - }); - - return remoteState.copyWith(read: preservedReads.toList()); - } - - int _sortByCreatedAt(Message a, Message b) => a.createdAt.compareTo(b.createdAt); - - /// The channel state related to this client. - ChannelState get _channelState => _channelStateController.value; - - /// The channel state related to this client as a stream. - Stream get channelStateStream => _channelStateController.stream; - - /// The channel state related to this client. - ChannelState get channelState => _channelStateController.value; - late BehaviorSubject _channelStateController; - - late final _debouncedUpdatePersistenceChannelState = debounce( - (ChannelState state) { - final persistenceClient = _client.chatPersistenceClient; - return persistenceClient?.updateChannelState(state); - }, - const Duration(seconds: 1), - ); - - set _channelState(ChannelState v) { - _channelStateController.safeAdd(v); - _debouncedUpdatePersistenceChannelState.call([v]); - } - - late final _debouncedUpdatePersistenceChannelThreads = debounce( - (Map> threads) async { - final channelCid = _channel.cid; - if (channelCid == null) return; - - final persistenceClient = _client.chatPersistenceClient; - return persistenceClient?.updateChannelThreads(channelCid, threads); - }, - const Duration(seconds: 1), - ); - - /// The channel threads related to this channel. - Map> get threads => {..._threadsController.value}; - - /// The channel threads related to this channel as a stream. - Stream>> get threadsStream => _threadsController; - final _threadsController = BehaviorSubject.seeded(>{}); - set _threads(Map> threads) { - _threadsController.safeAdd(threads); - _debouncedUpdatePersistenceChannelThreads.call([threads]); - } - - /// Clears all the replies in the thread identified by [parentId]. - void clearThread(String parentId) { - final updatedThreads = { - ...threads, - parentId: [], - }; - - _threads = updatedThreads; - } - - /// Update threads with updated information about messages. - void updateThreadInfo(String parentId, List messages) { - final updatedThreads = {...threads}; - - final threadMessages = updatedThreads[parentId] ?? []; - final updatedThreadMessages = _mergeMessagesIntoExisting( - existing: threadMessages, - toMerge: messages.where((it) => it.id != parentId), - ); - - // Update the thread with the modified message list. - updatedThreads[parentId] = updatedThreadMessages.toList(); - - _threads = updatedThreads; - } - - Draft? _getThreadDraft(String parentId, List? messages) { - return messages?.firstWhereOrNull((it) => it.id == parentId)?.draft; - } - - /// Draft for a specific thread identified by [parentId]. - Draft? threadDraft(String parentId) => _getThreadDraft(parentId, messages); - - /// Stream of draft for a specific thread identified by [parentId]. - /// - /// This stream emits a new value whenever the draft associated with the - /// specified thread is updated or removed. - Stream threadDraftStream(String parentId) => - channelStateStream.map((cs) => _getThreadDraft(parentId, cs.messages)).distinct(); - - /// Channel related typing users stream. - Stream> get typingEventsStream => _typingEventsController.stream; - - /// Channel related typing users last value. - Map get typingEvents => _typingEventsController.value; - final _typingEventsController = BehaviorSubject.seeded({}); - - void _listenTypingEvents() { - _subscriptions - ..add( - _channel.on(EventType.typingStart).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) return; - - final events = {...typingEvents, user: event}; - _typingEventsController.safeAdd(events); - }, - ), - ) - ..add( - _channel.on(EventType.typingStop).listen( - (event) { - final user = event.user; - if (user == null) return; - - final currentUser = _client.state.currentUser; - if (event.isFromUser(userId: currentUser?.id)) return; - - final events = {...typingEvents}..remove(user); - _typingEventsController.safeAdd(events); - }, - ), - ); - } - - Timer? _staleTypingEventsCleanerTimer; - - // Checks and removes stale typing events that were not explicitly stopped by - // the sender due to technical difficulties. e.g. process death, loss of - // Internet connection or custom implementation. - void _startCleaningStaleTypingEvents() { - _staleTypingEventsCleanerTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - final now = DateTime.now(); - typingEvents.forEach((user, event) { - if (now.difference(event.createdAt).inSeconds > incomingTypingStartEventTimeout) { - _client.handleEvent( - Event( - type: EventType.typingStop, - user: user, - cid: _channel.cid, - parentId: event.parentId, - ), - ); - } - }); - }, - ); - } - - Timer? _stalePinnedMessagesCleanerTimer; - - // Checks and removes stale pinned messages that are not valid anymore. - void _startCleaningStalePinnedMessages() { - _stalePinnedMessagesCleanerTimer = Timer.periodic( - const Duration(seconds: 30), - (_) { - final now = DateTime.now(); - var expiredMessages = channelState.pinnedMessages?.where((m) => m.pinExpires?.isBefore(now) == true).toList(); - if (expiredMessages != null && expiredMessages.isNotEmpty) { - expiredMessages = expiredMessages - .map( - (m) => m.copyWith( - pinExpires: null, - pinned: false, - ), - ) - .toList(); - - updateChannelState( - _channelState.copyWith( - pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), - messages: expiredMessages, - ), - ); - } - }, - ); - } - - Timer? _staleLiveLocationsCleanerTimer; - void _startCleaningExpiredLocations() { - _staleLiveLocationsCleanerTimer?.cancel(); - _staleLiveLocationsCleanerTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - final currentUserId = _channel._client.state.currentUser?.id; - if (currentUserId == null) return; - - final expired = activeLiveLocations.where((it) => it.isExpired); - if (expired.isEmpty) return; - - for (final sharedLocation in expired) { - // Skip if the location is shared by the current user, - // as we are already handling them in the client. - if (sharedLocation.userId == currentUserId) continue; - - final lastUpdatedAt = DateTime.timestamp(); - final locationExpiredEvent = Event( - type: EventType.locationExpired, - cid: sharedLocation.channelCid, - message: Message( - id: sharedLocation.messageId, - updatedAt: lastUpdatedAt, - sharedLocation: sharedLocation.copyWith( - updatedAt: lastUpdatedAt, - ), - ), - ); - - _channel._client.handleEvent(locationExpiredEvent); - } - }, - ); - } - - // Listens to channel push preference update events and updates the state - void _listenChannelPushPreferenceUpdated() { - _subscriptions.add( - _channel.on(EventType.channelPushPreferenceUpdated).listen( - (event) { - final pushPreferences = event.channelPushPreference; - if (pushPreferences == null) return; - - updateChannelState( - channelState.copyWith( - pushPreferences: pushPreferences, - ), - ); - }, - ), - ); - } - - Future _deleteMessagesFromUser({ - required String userId, - bool hardDelete = false, - DateTime? deletedAt, - }) async { - // Delete messages from persistence. - // - // Note: We perform this operation separately even though [_removeMessages] - // already handles it as we need to delete all messages from the user, not - // only the ones present in the current state. - final persistence = _channel.client.chatPersistenceClient; - await persistence?.deleteMessagesFromUser( - userId: userId, - cid: _channel.cid, - hardDelete: hardDelete, - deletedAt: deletedAt, - ); - - // Gather messages to delete from state. - final userMessages = {}; - for (final message in [...messages, ...threads.values.flattened]) { - if (message.user?.id != userId) continue; - userMessages[message.id] = message.copyWith( - type: MessageType.deleted, - deletedAt: deletedAt ?? DateTime.now(), - state: switch (hardDelete) { - true => MessageState.hardDeleted, - false => MessageState.softDeleted, - }, - ); - } - - final messagesToDelete = userMessages.values; - return _deleteMessages(messagesToDelete, hardDelete: hardDelete); - } - - void _deleteMessages( - Iterable messages, { - bool hardDelete = false, - }) { - if (messages.isEmpty) return; - - if (hardDelete) return _removeMessages(messages); - return _updateMessages(messages, upsert: false); - } - - void _updateMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (messages.isEmpty) return; - - _updateThreadMessages(messages, update: update, upsert: upsert); - _updateChannelMessages(messages, update: update, upsert: upsert); - _updatePinnedMessages(messages, update: update); - _updateActiveLiveLocations(messages); - } - - void _updateThreadMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (messages.isEmpty) return; - - // Group messages by parentId so each thread merge only sees its own - // replies — passing the full batch to every thread would leak replies - // across thread boundaries (the merge dedups by id, not by parentId). - final messagesByThread = >{}; - for (final m in messages) { - if (m.parentId case final parentId?) (messagesByThread[parentId] ??= []).add(m); - } - - // If there are no affected threads, return early. - if (messagesByThread.isEmpty) return; - - final updatedThreads = {...threads}; - for (final MapEntry(key: thread, :value) in messagesByThread.entries) { - final existingThreadMessages = updatedThreads[thread]; - - // Don't create a phantom entry for a thread that wasn't loaded: with - // `upsert: false` an out-of-window reply is dropped, so there's nothing - // to merge. Writing it back would make `threads.containsKey(parentId)` - // report a thread that was never paged in. - if (existingThreadMessages == null && !upsert) continue; - - final threadMessages = existingThreadMessages ?? []; - final updatedThreadMessages = _mergeMessagesIntoExisting( - existing: threadMessages, - toMerge: value, - update: update, - upsert: upsert, - ); - - // Update the thread with the modified message list. - updatedThreads[thread] = updatedThreadMessages.toList(); - } - - // Update the threads map. - _threads = updatedThreads; - } - - void _updateChannelMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (messages.isEmpty) return; - - final affectedMessages = messages.map((it) { - // If it's not a thread message, consider it affected. - if (it.parentId == null) return it; - // If it's a thread message shown in channel, consider it affected. - if (it.showInChannel == true) return it; - - return null; // Thread message not shown in channel, ignore it. - }).nonNulls; - - // If there are no affected messages, return early. - if (affectedMessages.isEmpty) return; - - final channelMessages = [...this.messages]; - final updatedChannelMessages = _mergeMessagesIntoExisting( - existing: channelMessages, - toMerge: affectedMessages, - update: update, - upsert: upsert, - ); - - // Calculate the new last message at time. - var lastMessageAt = _channelState.channel?.lastMessageAt; - for (final message in affectedMessages) { - if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) { - lastMessageAt = [lastMessageAt, message.createdAt].nonNulls.max; - } - } - - _channelState = _channelState.copyWith( - messages: updatedChannelMessages.toList(), - channel: _channelState.channel?.copyWith(lastMessageAt: lastMessageAt), - ); - } - - void _updatePinnedMessages( - Iterable messages, { - Message Function(Message original, Message updated) update = _mergeUpdate, - }) { - if (messages.isEmpty) return; - - // No-op fast path: nothing was pinned, and nothing in the batch is - // becoming pinned — skip the merge/copyWith churn that would otherwise - // land right back on an empty `pinnedMessages` list. - if (pinnedMessages.isEmpty && messages.every((m) => !m.pinned)) return; - - final updatedPinnedMessages = _mergePinnedMessagesIntoExisting( - existing: pinnedMessages, - toMerge: messages, - update: update, - ); - - _channelState = _channelState.copyWith( - pinnedMessages: updatedPinnedMessages.toList(), - ); - } - - void _updateActiveLiveLocations(Iterable messages) { - if (messages.isEmpty) return; - - final activeLiveLocations = [...this.activeLiveLocations]; - final updatedActiveLiveLocations = _mergeActiveLocationsIntoExisting( - existing: activeLiveLocations, - toMerge: messages, - ); - - _channelState = _channelState.copyWith( - activeLiveLocations: updatedActiveLiveLocations.toList(), - ); - } - - Iterable _mergeActiveLocationsIntoExisting({ - required Iterable existing, - required Iterable toMerge, - }) { - if (toMerge.isEmpty) return existing; - - final mergedLocations = existing.mergeFrom( - toMerge, - key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), - value: (message) => message.sharedLocation, - update: (original, updated) => updated, - ); - - final toUpdateMap = {for (final m in toMerge) m.id: m}; - final updatedLocations = mergedLocations.where((it) { - // Remove the location if it's expired. - if (it.isExpired) return false; - - final updatedMessage = toUpdateMap[it.messageId]; - // Remove the location if the attached message is deleted. - if (updatedMessage?.isDeleted == true) return false; - - return true; - }); - - return updatedLocations; - } - - Iterable _mergePinnedMessagesIntoExisting({ - required Iterable existing, - required Iterable toMerge, - Message Function(Message original, Message updated) update = _mergeUpdate, - }) { - return _mergeMessagesIntoExisting( - existing: existing, - toMerge: toMerge, - update: update, - ).where(_pinIsValid); - } - - Iterable _mergeMessagesIntoExisting({ - required Iterable existing, - required Iterable toMerge, - Message Function(Message original, Message updated) update = _mergeUpdate, - bool upsert = true, - }) { - if (toMerge.isEmpty) return existing; - - // [update] decides whether each pair is reconciled (default — see - // `_mergeUpdate`) or replaced (`_replaceUpdate`, used by local rollback - // paths that don't want enrichment fallback to keep optimistic values). - // - // [upsert] controls whether ids not already in [existing] are inserted. - // Event-driven paths (`message.updated`, `message.deleted` soft) pass - // `upsert: false` so an out-of-window message isn't dropped into a gap - // between the loaded slice and history the client hasn't paged in yet. - final existingList = existing is List ? existing : existing.toList(); - var toMergeList = toMerge is List ? toMerge : toMerge.toList(); - - // Single-message fast path. The hot ingest path (server echoes, edits, - // reactions, read receipts) always lands here, and `lastIndexWhere` + - // `sortedUpsertAt` skips the O(N) keymap build that the two-pointer - // merge would otherwise do up front. - if (toMergeList.length == 1) { - final message = toMergeList.first; - final oldIndex = existingList.lastIndexWhere((it) => it.id == message.id); - - // upsert: false — skip update if message is not loaded - if (oldIndex == -1 && !upsert) return existingList; - - final resolved = oldIndex == -1 ? message : update(existingList[oldIndex], message); - - final mergedMessages = existingList.sortedUpsertAt( - oldIndex, - resolved, - update: update, - compare: _sortByCreatedAt, - ); - - // Non-delete updates can't change what embedded quotedMessage copies - // should display, so we can skip the rewrite entirely. - if (!resolved.isDeleted) return mergedMessages; - - return mergedMessages.updateIf( - (it) => it.quotedMessageId == resolved.id, - (it) => it.copyWith(quotedMessage: resolved), - ); - } - - // upsert: false - skip messages not loaded in the window - if (!upsert) { - final existingIds = {for (final m in existingList) m.id}; - toMergeList = toMergeList.where((m) => existingIds.contains(m.id)).toList(); - if (toMergeList.isEmpty) return existingList; - } - - // Batch path: receiver (`existingList`) is maintained sorted as a - // state invariant; `mergeSorted` sorts `toMergeList` internally and - // returns a sorted result. - final mergedMessages = existingList.mergeSorted( - toMergeList, - key: (message) => message.id, - update: update, - compare: _sortByCreatedAt, - ); - - // Refresh embedded `quotedMessage` refs only for messages quoting an - // incoming message that is now deleted. `updateIf` returns the same - // list reference when nothing matches, so steady-state allocates - // nothing for this step. - final deletedIds = toMergeList.where((m) => m.isDeleted).map((m) => m.id).toSet(); - if (deletedIds.isEmpty) return mergedMessages; - - final mergedById = {for (final m in mergedMessages) m.id: m}; - return mergedMessages.updateIf( - (it) => deletedIds.contains(it.quotedMessageId), - (it) => it.copyWith(quotedMessage: mergedById[it.quotedMessageId]), - ); - } - - void _removeMessages(Iterable messages) { - if (messages.isEmpty) return; - - final messageIds = messages.map((m) => m.id).toSet().toList(); - final persistenceClient = _channel.client.chatPersistenceClient; - // Remove the messages from the persistence client. - persistenceClient?.deleteMessageByIds(messageIds); - persistenceClient?.deletePinnedMessageByIds(messageIds); - - _removeThreadMessages(messages); - _removeChannelMessages(messages); - _removePinnedMessages(messages); - _removeActiveLiveLocations(messages); - } - - void _removeThreadMessages(Iterable messages) { - if (messages.isEmpty) return; - - final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; - // If there are no affected threads, return early. - if (affectedThreads.isEmpty) return; - - final updatedThreads = {...threads}; - for (final thread in affectedThreads) { - final threadMessages = updatedThreads[thread]; - // Continue if the thread doesn't exist. - if (threadMessages == null) continue; - - // Remove the deleted message from the thread messages and reference from - // other messages quoting it. - final updatedThreadMessages = _removeMessagesFromExisting( - existing: threadMessages, - toRemove: messages, - ); - - // If there are no more messages in the thread, remove the thread entry. - if (updatedThreadMessages.isEmpty) { - updatedThreads.remove(thread); - continue; - } - - // Otherwise, update the thread with the modified message list. - updatedThreads[thread] = updatedThreadMessages.toList(); - } - - // Update the threads map. - _threads = updatedThreads; - } - - void _removeChannelMessages(Iterable messages) { - if (messages.isEmpty) return; - - final affectedMessages = messages.map((it) { - // If it's not a thread message, consider it affected. - if (it.parentId == null) return it; - // If it's a thread message shown in channel, consider it affected. - if (it.showInChannel == true) return it; - - return null; // Thread message not shown in channel, ignore it. - }).nonNulls; - - // If there are no affected messages, return early. - if (affectedMessages.isEmpty) return; - - final channelMessages = [...this.messages]; - final updatedChannelMessages = _removeMessagesFromExisting( - existing: channelMessages, - toRemove: affectedMessages, - ); - - _channelState = _channelState.copyWith( - messages: updatedChannelMessages.toList(), - ); - } - - void _removePinnedMessages(Iterable messages) { - if (messages.isEmpty) return; - - final pinnedMessages = [...this.pinnedMessages]; - final updatedPinnedMessages = _removePinnedMessagesFromExisting( - existing: pinnedMessages, - toRemove: messages, - ); - - _channelState = _channelState.copyWith( - pinnedMessages: updatedPinnedMessages.toList(), - ); - } - - void _removeActiveLiveLocations(Iterable messages) { - if (messages.isEmpty) return; - - final activeLiveLocations = [...this.activeLiveLocations]; - final updatedActiveLiveLocations = _removeActiveLocationsFromExisting( - existing: activeLiveLocations, - toRemove: messages, - ); - - _channelState = _channelState.copyWith( - activeLiveLocations: updatedActiveLiveLocations.toList(), - ); - } - - Iterable _removeActiveLocationsFromExisting({ - required Iterable existing, - required Iterable toRemove, - }) { - if (toRemove.isEmpty) return existing; - - final toRemoveIds = toRemove.map((m) => m.id).toSet(); - final updatedLocations = existing.where( - // Remove the location if its attached message is in the toRemove list. - (it) => !toRemoveIds.contains(it.messageId), - ); - - return updatedLocations; - } - - Iterable _removePinnedMessagesFromExisting({ - required Iterable existing, - required Iterable toRemove, - }) { - return _removeMessagesFromExisting( - existing: existing, - toRemove: toRemove, - ).where(_pinIsValid); - } - - Iterable _removeMessagesFromExisting({ - required Iterable existing, - required Iterable toRemove, - }) { - if (toRemove.isEmpty) return existing; - - final toRemoveIds = toRemove.map((m) => m.id).toSet(); - final updatedMessages = existing - .where((it) { - // Remove the message if it's in the toRemove list. - return !toRemoveIds.contains(it.id); - }) - .map((it) { - // Continue if the message doesn't quote any of the deleted messages. - if (!toRemoveIds.contains(it.quotedMessageId)) return it; - - // Setting it to null will remove the quoted message from the message. - return it.copyWith(quotedMessageId: null, quotedMessage: null); - }); - - return updatedMessages; - } - - // Listens to user message deleted events and marks messages from that user - // as either soft or hard deleted based on the event data. - void _listenUserMessagesDeleted() { - _subscriptions.add( - _channel.on(EventType.userMessagesDeleted).listen((event) async { - final user = event.user; - if (user == null) return; - - return _deleteMessagesFromUser( - userId: user.id, - hardDelete: event.hardDelete ?? false, - deletedAt: event.createdAt, - ); - }), - ); - } - - /// Call this method to dispose this object. - void dispose() { - _debouncedUpdatePersistenceChannelThreads.cancel(); - _debouncedUpdatePersistenceChannelState.cancel(); - _retryQueue.dispose(); - _subscriptions.cancel(); - _channelStateController.close(); - _isUpToDateController.close(); - _threadsController.close(); - _staleTypingEventsCleanerTimer?.cancel(); - _stalePinnedMessagesCleanerTimer?.cancel(); - _staleLiveLocationsCleanerTimer?.cancel(); - _typingEventsController.close(); - } -} - -bool _pinIsValid(Message message) { - // If the message is deleted, the pin is not valid. - if (message.isDeleted) return false; - - // If the message is not pinned, it's not valid. - if (message.pinned != true) return false; - - // If there's no expiration, the pin is valid. - final pinExpires = message.pinExpires; - if (pinExpires == null) return true; - - // If there's an expiration, check if it's still valid. - return pinExpires.isAfter(DateTime.now()); -} - -/// Extension methods for reading related operations on a ChannelClientState. -extension ChannelReadHelper on ChannelClientState { - /// Get the [Read] object for a specific user identified by [userId]. - Read? userReadOf({String? userId}) => read.userReadOf(userId: userId); - - /// Stream of [Read] object for a specific user identified by [userId]. - Stream userReadStreamOf({String? userId}) { - return readStream.map((read) => read.userReadOf(userId: userId)); - } - - /// Returns the list of [Read]s that have marked the given [msg] as read. - /// - /// The [Read] is considered to have read the message if: - /// - The read user is not the sender of the message. - /// - The read's lastRead is after or equal to the message's createdAt. - List readsOf({required Message message}) { - return read.readsOf(message: message); - } - - /// Stream of list of [Read]s that have marked the given [msg] as read. - /// - /// The [Read] is considered to have read the message if: - /// - The read user is not the sender of the message. - /// - The read's lastRead is after or equal to the message's createdAt. - Stream> readsOfStream({required Message message}) { - return readStream.map((read) => read.readsOf(message: message)); - } - - /// Returns the list of [Read]s that have marked the given [message] as - /// delivered. - /// - /// The [Read] is considered to have delivered the message if: - /// - The read user is not the sender of the message. - /// - The read contains a non-null lastDeliveredAt. - /// - The read's lastDeliveredAt is after or equal to the message's createdAt. - List deliveriesOf({required Message message}) { - return read.deliveriesOf(message: message); - } - - /// Stream of list of [Read]s that have marked the given [message] as - /// delivered. - /// - /// The [Read] is considered to have delivered the message if: - /// - The read user is not the sender of the message. - /// - The read contains a non-null lastDeliveredAt. - /// - The read's lastDeliveredAt is after or equal to the message's createdAt. - Stream> deliveriesOfStream({required Message message}) { - return readStream.map((read) => read.deliveriesOf(message: message)); - } -} - -/// Extension methods for checking channel capabilities on a Channel instance. -/// -/// These methods provide a convenient way to check if the current user has -/// specific capabilities in a channel. -extension ChannelCapabilityCheck on Channel { - /// True, if the current user can send a message to this channel. - bool get canSendMessage { - return ownCapabilities.contains(ChannelCapability.sendMessage); - } - - /// True, if the current user can send a reply to this channel. - bool get canSendReply { - return ownCapabilities.contains(ChannelCapability.sendReply); - } - - /// True, if the current user can send a message with restricted visibility. - bool get canSendRestrictedVisibilityMessage { - return ownCapabilities.contains( - ChannelCapability.sendRestrictedVisibilityMessage, - ); - } - - /// True, if the current user can send reactions. - bool get canSendReaction { - return ownCapabilities.contains(ChannelCapability.sendReaction); - } - - /// True, if the current user can attach links to messages. - bool get canSendLinks { - return ownCapabilities.contains(ChannelCapability.sendLinks); - } - - /// True, if the current user can attach files to messages. - bool get canCreateAttachment { - return ownCapabilities.contains(ChannelCapability.createAttachment); - } - - /// True, if the current user can freeze or unfreeze channel. - bool get canFreezeChannel { - return ownCapabilities.contains(ChannelCapability.freezeChannel); - } - - /// True, if the current user can enable or disable slow mode. - bool get canSetChannelCooldown { - return ownCapabilities.contains(ChannelCapability.setChannelCooldown); - } - - /// True, if the current user can leave channel (remove own membership). - bool get canLeaveChannel { - return ownCapabilities.contains(ChannelCapability.leaveChannel); - } - - /// True, if the current user can join channel (add own membership). - bool get canJoinChannel { - return ownCapabilities.contains(ChannelCapability.joinChannel); - } - - /// True, if the current user can pin a message. - bool get canPinMessage { - return ownCapabilities.contains(ChannelCapability.pinMessage); - } - - /// True, if the current user can delete any message from the channel. - bool get canDeleteAnyMessage { - return ownCapabilities.contains(ChannelCapability.deleteAnyMessage); - } - - /// True, if the current user can delete own messages from the channel. - bool get canDeleteOwnMessage { - return ownCapabilities.contains(ChannelCapability.deleteOwnMessage); - } - - /// True, if the current user can update any message in the channel. - bool get canUpdateAnyMessage { - return ownCapabilities.contains(ChannelCapability.updateAnyMessage); - } - - /// True, if the current user can update own messages in the channel. - bool get canUpdateOwnMessage { - return ownCapabilities.contains(ChannelCapability.updateOwnMessage); - } - - /// True, if the current user can use message search. - bool get canSearchMessages { - return ownCapabilities.contains(ChannelCapability.searchMessages); - } - - /// True, if the current user can send typing events. - @Deprecated('Use canUseTypingEvents instead') - bool get canSendTypingEvents { - if (canUseTypingEvents) return true; - return ownCapabilities.contains(ChannelCapability.sendTypingEvents); - } - - /// True, if the current user can upload message attachments. - bool get canUploadFile { - return ownCapabilities.contains(ChannelCapability.uploadFile); - } - - /// True, if the current user can delete channel. - bool get canDeleteChannel { - return ownCapabilities.contains(ChannelCapability.deleteChannel); - } - - /// True, if the current user can update channel data. - bool get canUpdateChannel { - return ownCapabilities.contains(ChannelCapability.updateChannel); - } - - /// True, if the current user can update channel members. - bool get canUpdateChannelMembers { - return ownCapabilities.contains(ChannelCapability.updateChannelMembers); - } - - /// True, if the current user can update thread data. - bool get canUpdateThread { - return ownCapabilities.contains(ChannelCapability.updateThread); - } - - /// True, if the current user can quote a message. - bool get canQuoteMessage { - return ownCapabilities.contains(ChannelCapability.quoteMessage); - } - - /// True, if the current user can ban channel members. - bool get canBanChannelMembers { - return ownCapabilities.contains(ChannelCapability.banChannelMembers); - } - - /// True, if the current user can flag a message. - bool get canFlagMessage { - return ownCapabilities.contains(ChannelCapability.flagMessage); - } - - /// True, if the current user can mute a channel. - bool get canMuteChannel { - return ownCapabilities.contains(ChannelCapability.muteChannel); - } - - /// True, if the current user can send custom events. - bool get canSendCustomEvents { - return ownCapabilities.contains(ChannelCapability.sendCustomEvents); - } - - /// True, if the current user has read events capability. - @Deprecated('Use canUseReadReceipts instead') - bool get canReceiveReadEvents => canUseReadReceipts; - - /// True, if the current user has read events capability. - bool get canUseReadReceipts { - return ownCapabilities.contains(ChannelCapability.readEvents); - } - - /// True, if unread counts for this channel should be tracked locally, - /// on-device, rather than relying on the server. - /// - /// This is the case when [StreamChatClient.isLocalUnreadCountEnabled] is - /// enabled and the channel doesn't support read receipts (for example, - /// livestream channel types that disable read events). Channels that - /// support read receipts always rely on server-driven unread counts. - bool get usesLocalUnreadCount { - return _client.isLocalUnreadCountEnabled && !canUseReadReceipts; - } - - /// True, if the current user has connect events capability. - bool get canReceiveConnectEvents { - return ownCapabilities.contains(ChannelCapability.connectEvents); - } - - /// True, if the current user can send and receive typing events. - bool get canUseTypingEvents { - return ownCapabilities.contains(ChannelCapability.typingEvents); - } - - /// True, if channel slow mode is active. - bool get isInSlowMode { - return ownCapabilities.contains(ChannelCapability.slowMode); - } - - /// True, if the current user is allowed to post messages as usual even if the - /// channel is in slow mode. - bool get canSkipSlowMode { - return ownCapabilities.contains(ChannelCapability.skipSlowMode); - } - - /// True, if the current user can create a poll. - bool get canSendPoll { - return ownCapabilities.contains(ChannelCapability.sendPoll); - } - - /// True, if the current user can vote in a poll. - bool get canCastPollVote { - return ownCapabilities.contains(ChannelCapability.castPollVote); - } - - /// True, if the current user can query poll votes. - bool get canQueryPollVotes { - return ownCapabilities.contains(ChannelCapability.queryPollVotes); - } - - /// True, if the current user has delivery events capability. - bool get canUseDeliveryReceipts { - return ownCapabilities.contains(ChannelCapability.deliveryEvents); - } - - /// True, if the current user can share location in the channel. - bool get canShareLocation { - return ownCapabilities.contains(ChannelCapability.shareLocation); - } - - /// True, if the current user can send an "@channel" mention that notifies - /// all channel members. - bool get canNotifyChannel { - return ownCapabilities.contains(ChannelCapability.notifyChannel); - } - - /// True, if the current user can send an "@here" mention that notifies all - /// online channel members. - bool get canNotifyHere { - return ownCapabilities.contains(ChannelCapability.notifyHere); - } - - /// True, if the current user can mention one or more roles in a message. - bool get canNotifyRole { - return ownCapabilities.contains(ChannelCapability.notifyRole); - } - - /// True, if the current user can mention one or more user groups in a - /// message. - bool get canNotifyGroup { - return ownCapabilities.contains(ChannelCapability.notifyGroup); - } -} diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart new file mode 100644 index 0000000000..8a0b26cc80 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -0,0 +1,2468 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:synchronized/synchronized.dart'; + +import '../../../stream_chat.dart'; + +/// The maximum time the incoming [Event.typingStart] event is valid before a +/// [Event.typingStop] event is emitted automatically. +const incomingTypingStartEventTimeout = 7; + +/// Class that manages a specific channel. +/// +/// #### Channel name +/// +/// {@template name} +/// If an optional [name] argument is provided in the constructor then it +/// will be set on [extraData] with a key of 'name'. +/// +/// ```dart +/// final channel = Channel(client, type, id, name: 'Channel name'); +/// print(channel.name == channel.extraData['name']); // true +/// ``` +/// +/// Before the channel is initialized the name can be set directly: +/// ```dart +/// channel.name = 'New channel name'; +/// ``` +/// +/// To update the name after the channel has been initialized, call: +/// ```dart +/// channel.updateName('Updated channel name'); +/// ``` +/// +/// This will do a partial update to update the name. +/// {@endtemplate} +/// +/// #### Channel image +/// +/// {@template image} +/// If an optional [image] argument is provided in the constructor then it +/// will be set on [extraData] with a key of 'image'. +/// +/// ```dart +/// final channel = Channel(client, type, id, image: 'https://getstream.io/image.png'); +/// print(channel.image == channel.extraData['image']); // true +/// ``` +/// +/// Before the channel is initialized the image can be set directly: +/// ```dart +/// channel.image = 'https://getstream.io/new-image'; +/// ``` +/// +/// To update the image after the channel has been initialized, call: +/// ```dart +/// channel.updateImage('https://getstream.io/new-image'); +/// ``` +/// +/// This will do a partial update to update the image. +/// {@endtemplate} +class Channel { + /// Class that manages a specific channel. + /// + /// Optional [extraData] and [image] properties can be provided. The [image] + /// is exposed to easily set a key of 'image' on [extraData]. + Channel( + this._client, + this._type, + this._id, { + String? name, + String? image, + Map? extraData, + }) : _cid = _id != null ? '$_type:$_id' : null, + _extraData = { + ...?extraData, + if (name != null) 'name': name, + if (image != null) 'image': image, + } { + _client.logger.info('New Channel instance created, not yet initialized'); + } + + /// Create a channel client instance from a [ChannelState] object. + Channel.fromState(this._client, ChannelState channelState) + : assert( + channelState.channel != null, + 'No channel found inside channel state', + ), + _id = channelState.channel!.id, + _type = channelState.channel!.type, + _cid = channelState.channel!.cid, + _extraData = channelState.channel!.extraData { + _initState(channelState); // Initialize the state immediately. + } + + /// This client state + ChannelClientState? state; + + /// The channel type + final String _type; + + String? _id; + String? _cid; + final Map _extraData; + + /// Shortcut to set channel name. + /// + /// {@macro name} + set name(String? name) { + if (_isInitialized) { + throw StateError( + 'Once the channel is initialized you should use `channel.updateName` ' + 'to update the channel name', + ); + } + _extraData.addAll({'name': name}); + } + + /// Shortcut to set channel image. + /// + /// {@macro image} + set image(String? image) { + if (_isInitialized) { + throw StateError( + 'Once the channel is initialized you should use `channel.updateImage` ' + 'to update the channel image', + ); + } + _extraData.addAll({'image': image}); + } + + set extraData(Map extraData) { + if (_isInitialized) { + throw StateError( + 'Once the channel is initialized you should use `channel.update` ' + 'to update channel data', + ); + } + _extraData.addAll(extraData); + } + + /// Whether this channel is identified by its member set rather than an + /// explicit id. + /// + /// Stream auto-generates ids of the form `!members-` for channels + /// created with members but no id, so the same set of users always + /// references the same channel. + /// + /// Distinct channels can lose members but can't gain them after creation. + /// + /// See [isOneToOne] for the typical 1-to-1 predicate built on this. + bool get isDistinct => id?.startsWith('!members') == true; + + /// Whether this is a group channel. + /// + /// True when the channel has more than two members, or isn't [isDistinct]. + /// Custom-id channels are treated as groups regardless of current member + /// count because they aren't bounded — they can grow back into + /// multi-person conversations. + /// + /// Near-inverse of [isOneToOne]. + bool get isGroup => (memberCount ?? 0) > 2 || !isDistinct; + + /// Whether this is a 1-to-1 conversation. + /// + /// True when the channel is [isDistinct] and has exactly two members. + /// Distinct channels can't gain members, so a 2-member distinct channel + /// is permanently bounded to two participants — including channels that + /// shrunk down from a larger group DM. + /// + /// This is a structural predicate without a current-user check. Combine + /// with capability / permission checks at the call site if you need + /// perspective gating. + /// + /// Near-inverse of [isGroup]. + bool get isOneToOne => isDistinct && memberCount == 2; + + /// Returns true if the channel is muted. + bool get isMuted { + final channelMutes = _client.state.currentUser?.channelMutes; + if (channelMutes == null) return false; + + return channelMutes.any((it) => it.channel.cid == cid); + } + + /// Returns true if the channel is muted, as a stream. + Stream get isMutedStream => _client.state.currentUserStream.map((user) { + final channelMutes = user?.channelMutes; + if (channelMutes == null) return false; + + return channelMutes.any((it) => it.channel.cid == cid); + }).distinct(); + + /// Channel configuration. + ChannelConfig? get config { + _checkInitialized(); + return state!.channelState.channel?.config; + } + + /// Channel configuration as a stream. + Stream get configStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.config); + } + + /// Relationship of the current user to this channel. + Member? get membership { + _checkInitialized(); + return state!.channelState.membership; + } + + /// Relationship of the current user to this channel as a stream. + Stream get membershipStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.membership); + } + + /// Channel user creator. + User? get createdBy { + _checkInitialized(); + return state!.channelState.channel?.createdBy; + } + + /// Channel user creator as a stream. + Stream get createdByStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.createdBy); + } + + /// Channel frozen status. + bool get frozen { + _checkInitialized(); + return state!.channelState.channel?.frozen == true; + } + + /// Channel frozen status as a stream. + Stream get frozenStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.frozen == true).distinct(); + } + + /// Channel disabled status. + bool get disabled { + _checkInitialized(); + return state!.channelState.channel?.disabled == true; + } + + /// Channel disabled status as a stream. + Stream get disabledStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.disabled == true).distinct(); + } + + /// Channel hidden status. + bool get hidden { + _checkInitialized(); + return state!.channelState.channel?.hidden == true; + } + + /// Channel hidden status as a stream. + Stream get hiddenStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.hidden == true).distinct(); + } + + /// Channel pinned status. + /// Status is specific to the current user. + bool get isPinned { + _checkInitialized(); + return membership?.pinnedAt != null; + } + + /// Channel pinned status as a stream. + /// Status is specific to the current user. + Stream get isPinnedStream { + return membershipStream.map((m) => m?.pinnedAt != null).distinct(); + } + + /// Channel archived status. + /// Status is specific to the current user. + bool get isArchived { + _checkInitialized(); + return membership?.archivedAt != null; + } + + /// Channel archived status as a stream. + /// Status is specific to the current user. + Stream get isArchivedStream { + return membershipStream.map((m) => m?.archivedAt != null).distinct(); + } + + /// The last date at which the channel got truncated. + DateTime? get truncatedAt { + _checkInitialized(); + return state!.channelState.channel?.truncatedAt; + } + + /// The last date at which the channel got truncated as a stream. + Stream get truncatedAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.truncatedAt).distinct(); + } + + /// Cooldown count + int get cooldown { + _checkInitialized(); + return state!.channelState.channel?.cooldown ?? 0; + } + + /// Cooldown count as a stream + Stream get cooldownStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.cooldown ?? 0).distinct(); + } + + /// Remaining cooldown duration in seconds for the channel. + /// + /// Returns 0 if there is no cooldown active. + /// + /// Optionally, provide [lastMessageAt] to calculate the remaining cooldown based on a specific message timestamp + /// instead of the last message sent by the current user in this channel. + int getRemainingCooldown({DateTime? lastMessageAt}) { + _checkInitialized(); + + final cooldownDuration = cooldown; + if (cooldownDuration <= 0) return 0; + + final userLastMessageAt = lastMessageAt ?? currentUserLastMessageAt; + if (userLastMessageAt == null) return 0; + + if (canSkipSlowMode) return 0; + + final currentTime = DateTime.timestamp(); + final elapsedTime = currentTime.difference(userLastMessageAt).inSeconds; + + return math.max(0, cooldownDuration - elapsedTime); + } + + /// Channel creation date. + DateTime? get createdAt { + _checkInitialized(); + return state!.channelState.channel?.createdAt; + } + + /// Channel creation date as a stream. + Stream get createdAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.createdAt).distinct(); + } + + /// Channel last message date. + DateTime? get lastMessageAt { + _checkInitialized(); + return state!.channelState.channel?.lastMessageAt; + } + + /// Channel last message date as a stream. + Stream get lastMessageAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.lastMessageAt).distinct(); + } + + DateTime? _currentUserLastMessageAt({ + required List? messages, + required Map> threads, + }) { + final currentUserId = client.state.currentUser?.id; + if (currentUserId == null) return null; + + bool ours(Message m) => !m.isEphemeral && m.user?.id == currentUserId; + + DateTime? max; + + if (messages != null) { + final idx = messages.lastIndexWhere(ours); + if (idx != -1) max = messages[idx].createdAt; + } + + for (final replies in threads.values) { + final idx = replies.lastIndexWhere(ours); + if (idx == -1) continue; + final createdAt = replies[idx].createdAt; + if (max == null || createdAt.isAfter(max)) max = createdAt; + } + + return max; + } + + /// The date of the last message sent by the current user. + /// + /// Returns null if the channel is not up to date or + /// if the current user has not sent any messages in this channel. + /// + /// Note: This includes both regular messages and thread messages. + DateTime? get currentUserLastMessageAt { + _checkInitialized(); + + // If the channel is not up to date, we can't rely on the last message + // from the current user. + if (!state!.isUpToDate) return null; + + final threads = state!.threads; + final messages = state!.channelState.messages; + + return _currentUserLastMessageAt(messages: messages, threads: threads); + } + + /// The date of the last message sent by the current user as a stream. + /// + /// Returns null if the channel is not up to date or + /// if the current user has not sent any messages in this channel. + /// + /// Note: This includes both regular messages and thread messages. + Stream get currentUserLastMessageAtStream { + _checkInitialized(); + + return CombineLatestStream.combine3( + state!.isUpToDateStream, + state!.channelStateStream.map((s) => s.messages).distinct(identical), + state!.threadsStream, + (isUpToDate, messages, threads) { + // If the channel is not up to date, we can't rely on the last message + // from the current user. + if (!isUpToDate) return null; + + return _currentUserLastMessageAt(messages: messages, threads: threads); + }, + ); + } + + /// Channel updated date. + DateTime? get updatedAt { + _checkInitialized(); + return state!.channelState.channel?.updatedAt; + } + + /// Channel updated date as a stream. + Stream get updatedAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.updatedAt).distinct(); + } + + /// Channel deletion date. + DateTime? get deletedAt { + _checkInitialized(); + return state!.channelState.channel?.deletedAt; + } + + /// Channel deletion date as a stream. + Stream get deletedAtStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.deletedAt).distinct(); + } + + /// Channel member count. + int? get memberCount { + _checkInitialized(); + return state!.channelState.channel?.memberCount; + } + + /// Channel member count as a stream. + Stream get memberCountStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.memberCount).distinct(); + } + + /// Channel message count. + /// + /// Note: This field is only populated if the `count_messages` option is + /// enabled for your app. + int? get messageCount { + _checkInitialized(); + return state!.channelState.channel?.messageCount; + } + + /// Channel message count as a stream. + /// + /// Note: This field is only populated if the `count_messages` option is + /// enabled for your app. + Stream get messageCountStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.messageCount).distinct(); + } + + /// List of filter tags applied to this channel. + /// + /// Generally used for filtering channels while querying. + List? get filterTags { + _checkInitialized(); + return state!.channelState.channel?.filterTags; + } + + /// Channel id. + String? get id => state?.channelState.channel?.id ?? _id; + + /// Channel type. + String get type => state?.channelState.channel?.type ?? _type; + + /// Channel cid. + String? get cid => state?.channelState.channel?.cid ?? _cid; + + /// Channel team. + String? get team { + _checkInitialized(); + return state!.channelState.channel?.team; + } + + /// Channel extra data. + Map get extraData { + var data = state?.channelState.channel?.extraData; + if (data == null || data.isEmpty) { + data = _extraData; + } + return data; + } + + /// List of user permissions on this channel + List get ownCapabilities => state?.channelState.channel?.ownCapabilities ?? []; + + /// List of user permissions on this channel + Stream> get ownCapabilitiesStream { + _checkInitialized(); + return state!.channelStateStream.map((cs) => cs.channel?.ownCapabilities ?? []).distinct(); + } + + /// Channel extra data as a stream. + Stream> get extraDataStream { + _checkInitialized(); + return state!.channelStateStream.map( + (cs) => cs.channel?.extraData ?? _extraData, + ); + } + + /// Shortcut to get channel name. + /// + /// {@macro name} + String? get name => extraData['name'] as String?; + + /// Channel [name] as a stream. + /// + /// The channel needs to be initialized. + /// + /// {@macro name} + Stream get nameStream { + _checkInitialized(); + return extraDataStream.map((it) => it['name'] as String?).distinct(); + } + + /// Shortcut to get channel image. + /// + /// {@macro image} + String? get image => extraData['image'] as String?; + + /// Channel [image] as a stream. + /// + /// The channel needs to be initialized. + /// + /// {@macro image} + Stream get imageStream { + _checkInitialized(); + return extraDataStream.map((it) => it['image'] as String?).distinct(); + } + + /// The main Stream chat client. + StreamChatClient get client => _client; + final StreamChatClient _client; + + Completer _initializedCompleter = Completer(); + + /// True if this is initialized. + /// + /// Call [watch] to initialize the client or instantiate it using + /// [Channel.fromState]. + Future get initialized => _initializedCompleter.future; + + // Whether the channel is successfully initialized and not disposed. + bool get _isInitialized => _initializedCompleter.isCompleted && state != null; + + final _cancelableAttachmentUploadRequest = {}; + final _messageAttachmentsUploadCompleter = >{}; + + /// Cancels [attachmentId] upload request. Throws exception if the request + /// hasn't even started yet, Already completed or Already cancelled. + /// + /// Optionally, provide a [reason] for the cancellation. + void cancelAttachmentUpload( + String attachmentId, { + String? reason, + }) { + final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; + if (cancelToken == null) { + throw const StreamChatError( + "Upload request for this Attachment hasn't started yet or maybe " + 'Already completed', + ); + } + if (cancelToken.isCancelled) { + throw const StreamChatError('Upload request already cancelled'); + } + cancelToken.cancel(reason); + } + + /// Retries the failed [attachmentId] upload request. + Future retryAttachmentUpload(String messageId, String attachmentId) => + _uploadAttachments(messageId, [attachmentId]); + + Future _uploadAttachments( + String messageId, + Iterable attachmentIds, + ) { + var message = [ + ...state!.messages, + ...state!.threads.values.expand((messages) => messages), + ].firstWhereOrNull((it) => it.id == messageId); + + if (message == null) { + throw const StreamChatError('Error, Message not found'); + } + + final attachments = message.attachments.where((it) { + if (it.uploadState.isSuccess) return false; + return attachmentIds.contains(it.id); + }); + + if (attachments.isEmpty) { + client.logger.info('No attachments available to upload'); + if (message.attachments.every((it) => it.uploadState.isSuccess)) { + _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); + } + return Future.value(); + } + + client.logger.info('Found ${attachments.length} attachments'); + + void updateAttachment(Attachment attachment, {bool remove = false}) { + final index = message!.attachments.indexWhere( + (it) => it.id == attachment.id, + ); + if (index != -1) { + // update or remove attachment from message. + final List newAttachments; + if (remove) { + newAttachments = [...message!.attachments]..removeAt(index); + } else { + newAttachments = [...message!.attachments]..[index] = attachment; + } + + final updatedMessage = message!.copyWith(attachments: newAttachments); + state?.updateMessage(updatedMessage); + // updating original message for next iteration + message = message!.merge(updatedMessage); + } + } + + return Future.wait( + attachments.map((it) { + client.logger.info('Uploading ${it.id} attachment...'); + + final throttledUpdateAttachment = updateAttachment.throttled( + const Duration(milliseconds: 500), + ); + + void onSendProgress(int sent, int total) { + throttledUpdateAttachment([ + it.copyWith( + uploadState: UploadState.inProgress(uploaded: sent, total: total), + ), + ]); + } + + final isImage = it.type == AttachmentType.image; + final cancelToken = CancelToken(); + Future future; + if (isImage) { + future = sendImage( + it.file!, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: it.extraData, + ); + } else { + future = sendFile( + it.file!, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: it.extraData, + ); + } + _cancelableAttachmentUploadRequest[it.id] = cancelToken; + return future + .then((response) { + client.logger.info('Attachment ${it.id} uploaded successfully...'); + + // If the response is SendFileResponse, then we might also be getting + // thumbUrl in case of video. So we need to update the attachment with + // both the assetUrl and thumbUrl. + if (response is SendFileResponse) { + updateAttachment( + it.copyWith( + assetUrl: response.file, + thumbUrl: response.thumbUrl, + uploadState: const UploadState.success(), + ), + ); + } else { + updateAttachment( + it.copyWith( + imageUrl: response.file, + uploadState: const UploadState.success(), + ), + ); + } + }) + .catchError((e, stk) { + if (e is StreamChatNetworkError && e.type == .cancel) { + client.logger.info('Attachment ${it.id} upload cancelled'); + + // remove attachment from message if cancelled. + updateAttachment(it, remove: true); + return; + } + + client.logger.severe('error uploading the attachment', e, stk); + updateAttachment( + it.copyWith(uploadState: UploadState.failed(error: e.toString())), + ); + }) + .whenComplete(() { + throttledUpdateAttachment.cancel(); + _cancelableAttachmentUploadRequest.remove(it.id); + }); + }), + ).whenComplete(() { + final completer = _messageAttachmentsUploadCompleter.remove(messageId); + if (completer == null || completer.isCompleted) return; + + // Always complete with the latest message view so callers can decide + // success vs. partial failure by inspecting per-attachment upload + // states. Cancellation is still surfaced via `completeError` from the + // sendMessage/updateMessage/deleteMessage entry points. + completer.complete(message); + }); + } + + final _sendMessageLock = Lock(); + + /// Send a [message] to this channel. + /// + /// If [skipPush] is true the message will not send a push notification. + /// + /// Waits for a [_messageAttachmentsUploadCompleter] to complete + /// before actually sending the message. + Future sendMessage( + Message message, { + bool skipPush = false, + bool skipEnrichUrl = false, + }) async { + _checkInitialized(); + + // Clean up stale error messages before sending a new message. + state?.cleanUpStaleErrorMessages(); + + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); + + final quotedMessage = state!.messages.firstWhereOrNull( + (m) => m.id == message.quotedMessageId, + ); + // ignore: parameter_assignments + message = message.copyWith( + localCreatedAt: DateTime.now(), + user: _client.state.currentUser, + quotedMessage: quotedMessage, + state: MessageState.sending, + attachments: message.attachments.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith(uploadState: const UploadState.preparing()); + }, + ).toList(), + ); + + state?.updateMessage(message); + + try { + if (message.attachments.any((it) => !it.uploadState.isSuccess)) { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; + + _uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + ); + + // ignore: parameter_assignments + message = await attachmentsUploadCompleter.future; + + // Fail the whole message if any attachment failed to upload + if (message.attachments.any((it) => it.uploadState.isFailed)) { + throw const StreamChatError('Failed to upload one or more attachments'); + } + } + + // Validate the final message before sending it to the server. + if (MessageRules.canUpload(message) != true) { + client.logger.warning('Message is not valid for sending, removing it'); + + // Remove the message from state as it is invalid. + state!.deleteMessage(message, hardDelete: true); + throw const StreamChatError('Message is not valid for sending'); + } + + // Wait for the previous sendMessage call to finish. Otherwise, the order + // of messages will not be maintained. + final response = await _sendMessageLock.synchronized( + () => _client.sendMessage( + message, + id!, + type, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + final sentMessage = message + .updateWith(response.message) + .copyWith( + // Update the message state to sent. + state: MessageState.sent, + ); + + state?.updateMessage(sentMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.sendingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + final _updateMessageLock = Lock(); + + /// Updates the [message] in this channel. + /// + /// Waits for a [_messageAttachmentsUploadCompleter] to complete + /// before actually updating the message. + Future updateMessage( + Message message, { + bool skipPush = false, + bool skipEnrichUrl = false, + }) async { + _checkInitialized(); + + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); + + // ignore: parameter_assignments + message = message.copyWith( + state: MessageState.updating, + localUpdatedAt: DateTime.now(), + attachments: message.attachments.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith(uploadState: const UploadState.preparing()); + }, + ).toList(), + ); + + state?.updateMessage(message); + + try { + if (message.attachments.any((it) => !it.uploadState.isSuccess)) { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; + + _uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + ); + + // ignore: parameter_assignments + message = await attachmentsUploadCompleter.future; + + // Fail the whole message if any attachment failed to upload + if (message.attachments.any((it) => it.uploadState.isFailed)) { + throw const StreamChatError('Failed to upload one or more attachments'); + } + } + + // Wait for the previous update call to finish. Otherwise, the order of + // messages will not be maintained. + final response = await _updateMessageLock.synchronized( + () => _client.updateMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + final updateMessage = message + .updateWith(response.message) + .copyWith( + // Update the message state to updated. + state: MessageState.updated, + ); + + state?.updateMessage(updateMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.updatingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + /// Partially updates the [message] in this channel. + /// + /// Use [set] to define values to be set. + /// + /// Use [unset] to define values to be unset. + Future partialUpdateMessage( + Message message, { + Map? set, + List? unset, + bool skipEnrichUrl = false, + }) async { + _checkInitialized(); + + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); + + // ignore: parameter_assignments + message = message.copyWith( + state: MessageState.updating, + localUpdatedAt: DateTime.now(), + ); + + state?.updateMessage(message); + + try { + // Wait for the previous update call to finish. Otherwise, the order of + // messages will not be maintained. + final response = await _updateMessageLock.synchronized( + () => _client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + final updatedMessage = message + .updateWith(response.message) + .copyWith( + // Update the message state to updated. + state: MessageState.updated, + ); + + state?.updateMessage(updatedMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + final _deleteMessageLock = Lock(); + + /// Deletes the [message] for everyone. + /// + /// If [hard] is true, the message is permanently deleted from the server + /// and cannot be recovered. In this case, any attachments associated with the + /// message are also deleted from the server. + Future deleteMessage(Message message, {bool hard = false}) { + final deletionScope = MessageDeleteScope.deleteForAll(hard: hard); + + return _deleteMessage(message, scope: deletionScope); + } + + /// Deletes the [message] only for the current user. + /// + /// Note: This does not delete the message for other channel members and + /// they can still see the message. + Future deleteMessageForMe(Message message) { + const deletionScope = MessageDeleteScope.deleteForMe(); + + return _deleteMessage(message, scope: deletionScope); + } + + // Deletes the [message] from the channel. + // + // The [scope] defines whether to delete the message for everyone or just + // for the current user. + // + // If the message is a local message (not yet sent to the server) or a bounced + // error message, it is deleted locally without making an API call. + // + // If the message is deleted for everyone and [scope.hard] is true, the + // message is permanently deleted from the server and cannot be recovered. + // In this case, any attachments associated with the message are also deleted + // from the server. + Future _deleteMessage( + Message message, { + required MessageDeleteScope scope, + }) async { + _checkInitialized(); + + // Directly deleting the local messages and bounced error messages as they + // are not available on the server. + if (message.remoteCreatedAt == null || message.isBouncedWithError) { + _deleteLocalMessage(message); + // Returning empty response to mark the api call as success. + return EmptyResponse(); + } + + // ignore: parameter_assignments + message = message.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.now(), + deletedForMe: scope is DeleteForMe, + state: MessageState.deleting(scope: scope), + ); + + state?.deleteMessage(message, hardDelete: scope.hard); + + try { + // Wait for the previous delete call to finish. Otherwise, the order of + // messages will not be maintained. + final response = await _deleteMessageLock.synchronized( + () => switch (scope) { + DeleteForMe() => _client.deleteMessageForMe(message.id), + DeleteForAll() => _client.deleteMessage(message.id, hard: scope.hard), + }, + ); + + final deletedMessage = message.copyWith( + deletedForMe: scope is DeleteForMe, + state: MessageState.deleted(scope: scope), + ); + + state?.deleteMessage(deletedMessage, hardDelete: scope.hard); + // If hard delete, also delete the attachments from the server. + if (scope.hard) _deleteMessageAttachments(deletedMessage); + + return response; + } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.deletingFailed(scope: scope), + ); + + state?.deleteMessage(failedMessage, hardDelete: scope.hard); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?.scheduleRetry(failedMessage); + } + + rethrow; + } + } + + // Deletes a local [message] that is not yet sent to the server. + // + // This is typically called when a user wants to delete a message that they + // have composed but not yet sent, or if a message failed to send and the user + // wants to remove it from their local view. + void _deleteLocalMessage(Message message) { + state?.deleteMessage( + hardDelete: true, // Local messages are always hard deleted. + message.copyWith( + type: MessageType.deleted, + localDeletedAt: DateTime.now(), + state: MessageState.hardDeleted, + ), + ); + + // Removing the attachments upload completer to stop the `sendMessage` + // waiting for attachments to complete. + final completer = _messageAttachmentsUploadCompleter.remove(message.id); + completer?.completeError(const StreamChatError('Message deleted')); + } + + // Deletes all the attachments associated with the given [message] + // from the server. This is typically called when a message is hard deleted. + Future _deleteMessageAttachments(Message message) async { + final attachments = message.attachments; + final deleteFutures = attachments.map((it) async { + if (it.imageUrl case final url?) return deleteImage(url); + if (it.assetUrl case final url?) return deleteFile(url); + }); + + try { + await Future.wait(deleteFutures); + } catch (e, stk) { + _client.logger.warning('Error deleting message attachments', e, stk); + } + } + + /// Retries operations on a message based on its failed state. + /// + /// This method examines the message's state and performs the appropriate + /// retry action: + /// - For [MessageState.sendingFailed], it attempts to send the message. + /// - For [MessageState.updatingFailed], it attempts to update the message. + /// - For [MessageState.partialUpdatingFailed], it attempts to partially + /// update the message with the same 'set' and 'unset' parameters that were + /// used in the original request. + /// - For [MessageState.deletingFailed], it attempts to delete the message + /// again, using the same scope (for me or for all) as the original request. + /// - For messages with [isBouncedWithError], it attempts to send the message. + /// + /// Throws a [StateError] if the message is not in a failed state or + /// bounced with an error. + Future retryMessage(Message message) async { + assert( + message.state.isFailed || message.isBouncedWithError, + 'Only failed or bounced messages can be retried', + ); + + return message.state.maybeWhen( + failed: (state, _) => state.when( + sendingFailed: (skipPush, skipEnrichUrl) => sendMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + updatingFailed: (skipPush, skipEnrichUrl) => updateMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + partialUpdatingFailed: (set, unset, skipEnrichUrl) { + return partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ); + }, + deletingFailed: (scope) => switch (scope) { + DeleteForMe() => deleteMessageForMe(message), + DeleteForAll(hard: final hard) => deleteMessage(message, hard: hard), + }, + ), + orElse: () { + // Check if the message is bounced with error. + if (message.isBouncedWithError) return sendMessage(message); + + throw StateError( + 'Only failed or bounced messages can be retried', + ); + }, + ); + } + + /// Pins provided message + Future pinMessage( + Message message, { + 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( + message, + set: { + 'pinned': true, + 'pin_expires': pinExpires?.toUtc().toIso8601String(), + }, + ); + } + + /// Unpins provided message. + Future unpinMessage(Message message) => partialUpdateMessage( + message, + set: { + 'pinned': false, + }, + ); + + /// Creates or updates a new [draft] for this channel. + Future createDraft( + DraftMessage draft, + ) { + _checkInitialized(); + return _client.createDraft(draft, id!, type); + } + + /// Retrieves the draft for this channel. + /// + /// Optionally, provide a [parentId] to get the draft for a specific thread. + Future getDraft({ + String? parentId, + }) { + _checkInitialized(); + return _client.getDraft(id!, type, parentId: parentId); + } + + /// Deletes the draft for this channel. + /// + /// Optionally, provide a [parentId] to delete the draft for a specific + /// thread. + Future deleteDraft({ + String? parentId, + }) { + _checkInitialized(); + return _client.deleteDraft(id!, type, parentId: parentId); + } + + /// Sends a static location to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future sendStaticLocation({ + String? id, + String? messageText, + String? createdByDeviceId, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + + /// Sends a live location sharing message to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future startLiveLocationSharing({ + String? id, + String? messageText, + String? createdByDeviceId, + required DateTime endSharingAt, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + endAt: endSharingAt, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + + /// Send a file to this channel. + Future sendFile( + AttachmentFile file, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.sendFile( + file, + id!, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Send an image to this channel. + Future sendImage( + AttachmentFile file, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.sendImage( + file, + id!, + type, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Search for a message with the given options. + Future search({ + String? query, + Filter? messageFilters, + List? sort, + PaginationParams? paginationParams, + }) { + _checkInitialized(); + return _client.search( + Filter.in_('cid', [cid!]), + sort: sort, + query: query, + paginationParams: paginationParams, + messageFilters: messageFilters, + ); + } + + /// Delete a file from this channel. + Future deleteFile( + String url, { + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.deleteFile( + url, + id!, + type, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Delete an image from this channel. + Future deleteImage( + String url, { + CancelToken? cancelToken, + Map? extraData, + }) { + _checkInitialized(); + return _client.deleteImage( + url, + id!, + type, + cancelToken: cancelToken, + extraData: extraData, + ); + } + + /// Send an event on this channel. + Future sendEvent(Event event) { + _checkInitialized(); + return _client.sendEvent(id!, type, event); + } + + final _pollLock = Lock(); + + /// Send a message with a poll to this channel. + /// + /// Optionally provide a [messageText] to send a message along with the poll. + Future sendPoll( + Poll poll, { + String? messageText, + }) async { + _checkInitialized(); + final res = await _pollLock.synchronized(() => _client.createPoll(poll)); + return sendMessage( + Message( + text: messageText, + poll: res.poll, + pollId: res.poll.id, + ), + ); + } + + /// Updates the [poll] in this channel. + Future updatePoll(Poll poll) { + _checkInitialized(); + return _pollLock.synchronized(() => _client.updatePoll(poll)); + } + + /// Deletes the given [poll] from this channel. + Future deletePoll(Poll poll) { + _checkInitialized(); + return _pollLock.synchronized(() => _client.deletePoll(poll.id)); + } + + /// Close the given [poll]. + Future closePoll(Poll poll) { + _checkInitialized(); + return _pollLock.synchronized(() => _client.closePoll(poll.id)); + } + + /// Create a new poll option for the given [poll]. + Future createPollOption( + Poll poll, + PollOption option, + ) { + _checkInitialized(); + return _pollLock.synchronized( + () => _client.createPollOption(poll.id, option), + ); + } + + final _pollVoteLock = Lock(); + + /// Cast a vote on the given [poll] with the given [option]. + Future castPollVote( + Message message, + Poll poll, + PollOption option, + ) async { + _checkInitialized(); + + final optionId = option.id; + if (optionId == null) { + throw ArgumentError('Option id cannot be null'); + } + + return _pollVoteLock.synchronized( + () => _client.castPollVote( + message.id, + poll.id, + optionId: optionId, + ), + ); + } + + /// Add a new answer to the given [poll]. + Future addPollAnswer( + Message message, + Poll poll, { + required String answerText, + }) { + _checkInitialized(); + return _pollVoteLock.synchronized( + () => _client.addPollAnswer( + message.id, + poll.id, + answerText: answerText, + ), + ); + } + + /// Remove a vote on the given [poll] with the given [vote]. + Future removePollVote( + Message message, + Poll poll, + PollVote vote, + ) { + _checkInitialized(); + + final voteId = vote.id; + if (voteId == null) { + throw ArgumentError('Vote id cannot be null'); + } + + return _pollVoteLock.synchronized( + () => _client.removePollVote( + message.id, + poll.id, + voteId, + ), + ); + } + + /// Query the poll votes for the given [pollId] with the given [filter] and + /// [sort] options. + Future queryPollVotes( + String pollId, { + Filter? filter, + SortOrder? sort, + PaginationParams pagination = const PaginationParams(), + }) { + _checkInitialized(); + return _client.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ); + } + + /// Create a reminder for the given [messageId]. + /// + /// Optionally, provide a [remindAt] date to set when the reminder should + /// be triggered. If not provided, the reminder will be created as a + /// bookmark type instead. + Future createReminder( + String messageId, { + DateTime? remindAt, + }) { + _checkInitialized(); + return _client.createReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Update an existing reminder with the given [reminderId]. + /// + /// Optionally, provide a [remindAt] date to set when the reminder should + /// be triggered. If not provided, the reminder will be updated as a + /// bookmark type instead. + Future updateReminder( + String messageId, { + DateTime? remindAt, + }) { + _checkInitialized(); + return _client.updateReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Remove the reminder for the given [messageId]. + Future deleteReminder(String messageId) { + _checkInitialized(); + return _client.deleteReminder(messageId); + } + + /// Send a reaction to this channel. + /// + /// Set [enforceUnique] to true to remove the existing user reaction. + Future sendReaction( + Message message, + Reaction reaction, { + bool skipPush = false, + bool enforceUnique = false, + }) async { + _checkInitialized(); + + final messageId = message.id; + // ignore: parameter_assignments + reaction = reaction.copyWith( + messageId: messageId, + user: _client.state.currentUser, + ); + + final updatedMessage = message.addMyReaction( + reaction, + enforceUnique: enforceUnique, + ); + + state?.updateMessage(updatedMessage); + + try { + final reactionResp = await _client.sendReaction( + messageId, + reaction, + skipPush: skipPush, + enforceUnique: enforceUnique, + ); + return reactionResp; + } catch (_) { + // Reset the message if the update fails. Use replace (not merge) + // so the rollback wins over the optimistic local state — otherwise + // `Message.updateWith`'s enrichment preservation would keep the + // optimistic `ownReactions` for messages that previously had none. + state?.replaceMessage(message); + rethrow; + } + } + + /// Delete a reaction from this channel. + Future deleteReaction( + Message message, + Reaction reaction, + ) async { + _checkInitialized(); + + final updatedMessage = message.deleteMyReaction( + reactionType: reaction.type, + ); + + state?.updateMessage(updatedMessage); + + try { + final deleteResponse = await _client.deleteReaction( + message.id, + reaction.type, + ); + return deleteResponse; + } catch (_) { + // Reset the message if the update fails. Use replace (not merge) + // for symmetry with `sendReaction` — see that method for context. + state?.replaceMessage(message); + rethrow; + } + } + + /// Sends an event to stop AI response generation, leaving the message in + /// its current state. + Future stopAIResponse() async { + return sendEvent( + Event( + type: EventType.aiIndicatorStop, + ), + ); + } + + /// Update the channel's [name]. + /// + /// This is the same as calling [updatePartial] and providing a map with a + /// 'name' key: + /// + /// ```dart + /// channel.updatePartial( + /// set: {'name': 'Updated channel name'} + /// ); + /// ``` + /// + /// Instead do: + /// ```dart + /// channel.updateName('Updated channel name'); + /// ``` + Future updateName(String name) => updatePartial(set: {'name': name}); + + /// Update the channel's [image]. + /// + /// This is the same as calling [updatePartial] and providing a map with an + /// 'image' key: + /// + /// ```dart + /// channel.updatePartial( + /// set: {'image': 'https://getstream.io/new-image'} + /// ); + /// ``` + /// + /// Instead do: + /// ```dart + /// channel.updateImage('https://getstream.io/new-image'); + /// ``` + Future updateImage(String image) => updatePartial(set: {'image': image}); + + /// Update the channel custom data. This replaces all of the channel data + /// with the given [channelData]. + /// + /// If you instead want to do a partial update, use [updatePartial]. + /// + /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart + /// for more information. + Future update( + Map channelData, { + Message? updateMessage, + }) async { + _checkInitialized(); + return _client.updateChannel( + id!, + type, + channelData, + message: updateMessage, + ); + } + + /// A partial update can be used to set and unset specific custom data fields + /// when it is necessary to retain additional custom data fields on the + /// object. + /// + /// - [set] will add, or update existing attributes. + /// - [unset] will remove the attributes with the provided list of + /// values (keys). + /// + /// If you want to do a full update/replacement, use [update] instead. + /// + /// See, https://getstream.io/chat/docs/other-rest/channel_update/?language=dart + /// for more information. + Future updatePartial({ + Map? set, + List? unset, + }) async { + _checkInitialized(); + return _client.updateChannelPartial(id!, type, set: set, unset: unset); + } + + /// Enable slow mode + Future enableSlowMode({ + required int cooldownInterval, + }) async { + _checkInitialized(); + return _client.enableSlowdown(id!, type, cooldownInterval); + } + + /// Disable slow mode + Future disableSlowMode() async { + _checkInitialized(); + return _client.disableSlowdown(id!, type); + } + + /// Delete this channel. Messages are permanently removed. + Future delete() async { + _checkInitialized(); + return _client.deleteChannel(id!, type); + } + + /// Removes all messages from the channel up to [truncatedAt] or now if + /// [truncatedAt] is not provided. + /// If [skipPush] is true, no push notification will be sent. + /// [Message] is the system message that will be sent to the channel. + Future truncate({ + Message? message, + bool? skipPush, + DateTime? truncatedAt, + }) async { + _checkInitialized(); + return _client.truncateChannel( + id!, + type, + message: message, + skipPush: skipPush, + truncatedAt: truncatedAt, + ); + } + + /// Accept invitation to the channel. + Future acceptInvite([Message? message]) async { + _checkInitialized(); + return _client.acceptChannelInvite(id!, type, message: message); + } + + /// Reject invitation to the channel. + Future rejectInvite([Message? message]) async { + _checkInitialized(); + return _client.rejectChannelInvite(id!, type, message: message); + } + + /// Add members to the channel. + Future addMembers( + List memberIds, { + Message? message, + bool hideHistory = false, + DateTime? hideHistoryBefore, + }) async { + _checkInitialized(); + return _client.addChannelMembers( + id!, + type, + memberIds, + message: message, + hideHistory: hideHistory, + hideHistoryBefore: hideHistoryBefore, + ); + } + + /// Invite members to the channel. + Future inviteMembers( + List memberIds, { + Message? message, + }) async { + _checkInitialized(); + return _client.inviteChannelMembers(id!, type, memberIds, message: message); + } + + /// Remove members from the channel. + Future removeMembers( + List memberIds, { + Message? message, + }) async { + _checkInitialized(); + return _client.removeChannelMembers(id!, type, memberIds, message: message); + } + + /// Send action for a specific message of this channel. + Future sendAction( + Message message, + Map formData, + ) async { + _checkInitialized(); + final messageId = message.id; + final res = await _client.sendAction(id!, type, messageId, formData); + + // update the passed message with response message + if (res.message != null) { + state!.updateMessage(res.message!); + } else { + // remove the passed message if response does + // not contain message + state!.removeMessage(message); + } + return res; + } + + /// Mark all messages as read. + /// + /// Optionally provide a [messageId] if you want to mark channel as + /// read from a particular message onwards. + /// + /// If [usesLocalUnreadCount] is `true` for this channel, this updates the + /// unread count locally, on-device, without making a network request. In + /// that case [messageId] is recorded as the read boundary but does **not** + /// narrow the count: the channel is always treated as fully read and the + /// count drops to zero. See [ChannelClientState.markReadLocally]. + Future markRead({String? messageId}) async { + _checkInitialized(); + + if (usesLocalUnreadCount) { + state!.markReadLocally(messageId: messageId); + return EmptyResponse(); + } + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark as read: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markChannelRead(id!, type, messageId: messageId); + } + + /// Marks the channel as unread by a given [messageId]. + /// + /// All messages from the provided message onwards will be marked as unread, + /// **including** the message itself. Contrast with + /// [markUnreadByTimestamp], which is exclusive: a message created at + /// exactly the given timestamp stays read. + /// + /// If [usesLocalUnreadCount] is `true` for this channel, this updates the + /// unread count locally, on-device, without making a network request. The + /// message must be part of the locally-known messages ([Channel.messages]) + /// for the count to be recomputed. + Future markUnread(String messageId) async { + _checkInitialized(); + + if (usesLocalUnreadCount) { + // [ChannelClientState.messages] is sorted ascending by `createdAt`, so + // the entry before the anchor is the newest message that stays read. + final messages = state!.messages; + final anchorIndex = messages.indexWhere((it) => it.id == messageId); + if (anchorIndex < 0) { + throw StreamChatError( + 'Cannot mark as unread: Message "$messageId" was not found in the ' + 'locally-known messages for this channel.', + ); + } + + final anchor = messages[anchorIndex]; + final previous = anchorIndex > 0 ? messages[anchorIndex - 1] : null; + + // Subtract a microsecond so the anchor message itself is treated as + // "after" the new read boundary, matching the "from the provided + // message onwards" semantics described above. Preferred over using + // `previous.createdAt` as the boundary, which would leak the anchor + // back into the read set if the two share an identical `createdAt`. + final lastRead = anchor.createdAt.subtract(const Duration(microseconds: 1)); + state!.markUnreadLocally(lastRead: lastRead, lastReadMessageId: previous?.id); + return EmptyResponse(); + } + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark as unread: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markChannelUnread(id!, type, messageId); + } + + /// Marks the channel as unread by a given [timestamp]. + /// + /// All messages after the provided timestamp will be marked as unread. This + /// boundary is **exclusive**: a message created at exactly [timestamp] stays + /// read. Contrast with [markUnread], which is inclusive of the message it is + /// given — `markUnread(m.id)` is equivalent to + /// `markUnreadByTimestamp(m.createdAt - 1µs)`, not to + /// `markUnreadByTimestamp(m.createdAt)`. + /// + /// If [usesLocalUnreadCount] is `true` for this channel, this updates the + /// unread count locally, on-device, without making a network request. + Future markUnreadByTimestamp(DateTime timestamp) async { + _checkInitialized(); + + if (usesLocalUnreadCount) { + // The newest locally-known message at or before the boundary is the last + // one that stays read. + final lastReadMessage = state!.messages.lastWhereOrNull( + (it) => !it.createdAt.isAfter(timestamp), + ); + + state!.markUnreadLocally( + lastRead: timestamp, + lastReadMessageId: lastReadMessage?.id, + ); + return EmptyResponse(); + } + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark as unread: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markChannelUnreadByTimestamp(id!, type, timestamp); + } + + /// Mark the thread with [threadId] in the channel as read. + Future markThreadRead(String threadId) async { + _checkInitialized(); + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark thread as read: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markThreadRead(id!, type, threadId); + } + + /// Mark the thread with [threadId] in the channel as unread. + Future markThreadUnread(String threadId) async { + _checkInitialized(); + + if (!canUseReadReceipts) { + throw const StreamChatError( + 'Cannot mark thread as unread: Channel does not support read events. ' + 'Enable read_events in your channel type configuration.', + ); + } + + return _client.markThreadUnread(id!, type, threadId); + } + + void _initState(ChannelState channelState) { + state = ChannelClientState(this, channelState); + _initializedCompleter.safeComplete(true); + + if (cid case final cid?) client.state.addChannels({cid: this}); + _client.logger.info('Channel ${channelState.channel?.cid} initialized'); + } + + /// Loads the initial channel state and watches for changes. + Future watch({ + bool presence = false, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) { + return query( + watch: true, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + } + + /// Stop watching the channel. + Future stopWatching() async { + _checkInitialized(); + return _client.stopChannelWatching(id!, type); + } + + /// List the message replies for a parent message. + /// + /// Set [preferOffline] to true to avoid the api call if the data is already + /// in the offline storage. + Future getReplies( + String parentId, { + PaginationParams? options, + bool preferOffline = false, + }) async { + QueryRepliesResponse? response; + + // If we prefer offline, we first try to get the replies from the + // offline storage. + if (preferOffline) { + if (_client.chatPersistenceClient case final persistenceClient?) { + final cachedReplies = await persistenceClient.getReplies( + parentId, + options: options, + ); + + // If the cached replies are not empty, we can use them. + if (cachedReplies.isNotEmpty) { + response = QueryRepliesResponse()..messages = cachedReplies; + } + } + } + + // If we still don't have the replies, we try to get them from the API. + response ??= await _client.getReplies(parentId, options: options); + + // Before updating the state, we check if we are querying around a + // reply, If we are, we have to clear the state to avoid potential + // gaps in the message sequence. + final isQueryingAround = switch (options) { + PaginationParams(idAround: _?) => true, + PaginationParams(createdAtAround: _?) => true, + _ => false, + }; + + if (isQueryingAround) state?.clearThread(parentId); + state?.updateThreadInfo(parentId, response.messages); + + return response; + } + + /// List the reactions for a message in the channel. + Future getReactions( + String messageId, { + PaginationParams? pagination, + }) => _client.getReactions( + messageId, + pagination: pagination, + ); + + /// Retrieves a list of messages by given [messageIDs]. + Future getMessagesById( + List messageIDs, + ) async { + _checkInitialized(); + return _client.getMessagesById(id!, type, messageIDs); + } + + /// Translate a message by given [messageId] and [language]. + /// + /// The translated message is merged into the channel state, so its + /// translations are available to everything listening to it without the + /// caller having to apply the response itself. + Future translateMessage( + String messageId, + String language, + ) async { + final response = await _client.translateMessage(messageId, language); + state?.updateMessage(response.message); + return response; + } + + /// Creates a new channel. + Future create() => query(state: false); + + /// Query the API, get messages, members or other channel fields. + /// + /// Set [preferOffline] to true to avoid the API call if the data is already + /// in the offline storage. + Future query({ + bool state = true, + bool watch = false, + bool presence = false, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + bool preferOffline = false, + }) async { + // A prior failed init left the completer errored; reset it so this attempt + // owns the `initialized` result. Must stay before the first `await` so a + // caller reading `initialized` right after `query()`/`watch()` begins sees + // the fresh completer. + if (_initializedCompleter.isCompleted && this.state == null) { + _initializedCompleter = Completer(); + } + + ChannelState? channelState; + + try { + // If we prefer offline, we first try to get the channel state from the + // offline storage. + if (preferOffline && !watch && cid != null) { + final persistenceClient = _client.chatPersistenceClient; + if (persistenceClient != null) { + final cachedState = await persistenceClient.getChannelStateByCid( + cid!, + messagePagination: messagesPagination, + ); + + // If the cached state contains messages, we can use it. + if (cachedState.messages?.isNotEmpty == true) { + channelState = cachedState; + } + } + } + + // If we still don't have the channelState, we try to get it from the API. + channelState ??= await _client.queryChannel( + type, + channelId: id, + channelData: _extraData, + state: state, + watch: watch, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); + + if (_id == null) { + _id = channelState.channel!.id; + _cid = channelState.channel!.cid; + } + + // Initialize the channel state if it's not initialized yet. + if (this.state == null) { + _initState(channelState); + } else { + // Otherwise, we update the existing state with the new channel state. + // + // But, before updating the state, we check if we are querying around a + // message, If we are, we have to truncate the state to avoid potential + // gaps in the message sequence. + final isQueryingAround = switch (messagesPagination) { + PaginationParams(idAround: _?) => true, + PaginationParams(createdAtAround: _?) => true, + _ => false, + }; + + if (isQueryingAround) this.state?.truncate(); + this.state?.updateChannelStateFromServer(channelState); + } + + // Submit for delivery reporting only when fetching the latest messages. + // This happens when no pagination params are provided (initial query). + if (messagesPagination == null) { + _client.channelDeliveryReporter.submitForDelivery([this]); + } + + return channelState; + } catch (e, stk) { + // If we failed to get the channel state from the API and we were not + // supposed to watch the channel, we will try to get the channel state + // from the offline storage. + if (watch == false) { + if (_client.persistenceEnabled) { + return _client.chatPersistenceClient!.getChannelStateByCid( + cid!, + messagePagination: messagesPagination, + ); + } + } + + // Otherwise, we will just rethrow the error. + _initializedCompleter.safeCompleteError(e, stk); + + rethrow; + } + } + + /// Query channel members. + Future queryMembers({ + Filter? filter, + SortOrder? sort, + PaginationParams? pagination, + }) => _client.queryMembers( + type, + channelId: id, + filter: filter, + members: state?.members, + sort: sort, + pagination: pagination, + ); + + /// Query channel banned users. + Future queryBannedUsers({ + Filter? filter, + SortOrder? sort, + PaginationParams? pagination, + }) { + _checkInitialized(); + filter ??= Filter.equal('channel_cid', cid!); + return _client.queryBannedUsers( + filter: filter, + sort: sort, + pagination: pagination, + ); + } + + // Timer to keep track of mute expiration. This is used to update the channel + // state when the mute expires. + Timer? _muteExpirationTimer; + + /// Mutes the channel. + Future mute({Duration? expiration}) { + _checkInitialized(); + + // If there is a expiration set, we will set a timer to automatically unmute + // the channel when the mute expires. + if (expiration != null) { + _muteExpirationTimer?.cancel(); + _muteExpirationTimer = Timer(expiration, unmute); + } + + return _client.muteChannel(cid!, expiration: expiration); + } + + /// Unmute the channel. + Future unmute() { + _checkInitialized(); + + // Cancel the mute expiration timer if it is set. + _muteExpirationTimer?.cancel(); + _muteExpirationTimer = null; + + return _client.unmuteChannel(cid!); + } + + /// Bans the member with given [userID] from the channel. + Future banMember( + String userID, + Map options, + ) async { + _checkInitialized(); + final opts = Map.from(options) + ..addAll({ + 'type': type, + 'id': id, + }); + return _client.banUser(userID, opts); + } + + /// Remove the ban for the member with given [userID] in the channel. + Future unbanMember(String userID) async { + _checkInitialized(); + return _client.unbanUser(userID, { + 'type': type, + 'id': id, + }); + } + + /// Shadow bans the user with the given [userID] from the channel. + Future shadowBan( + String userID, + Map options, + ) async { + _checkInitialized(); + final opts = Map.from(options) + ..addAll({ + 'type': type, + 'id': id, + }); + return _client.shadowBan(userID, opts); + } + + /// Remove the shadow ban for the user with the given [userID] in the channel. + Future removeShadowBan(String userID) async { + _checkInitialized(); + return _client.removeShadowBan(userID, { + 'type': type, + 'id': id, + }); + } + + /// Hides the channel from [StreamChatClient.queryChannels] for the user + /// until a message is added. + /// + /// If [clearHistory] is set to true - all messages + /// will be removed for the user. + Future hide({bool clearHistory = false}) async { + _checkInitialized(); + return _client.hideChannel( + id!, + type, + clearHistory: clearHistory, + ); + } + + /// Removes the hidden status for the channel. + Future show() async { + _checkInitialized(); + return _client.showChannel(id!, type); + } + + /// Pins the channel for the current user. + Future pin() async { + _checkInitialized(); + + final response = await _client.pinChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Unpins the channel. + Future unpin() async { + _checkInitialized(); + + final response = await _client.unpinChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Archives the channel. + Future archive() async { + _checkInitialized(); + + final response = await _client.archiveChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Unarchives the channel for the current user. + Future unarchive() async { + _checkInitialized(); + + final response = await _client.unarchiveChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Stream of [Event] coming from websocket connection specific for the + /// channel. Pass an eventType as parameter in order to filter just a type + /// of event. + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) => _client + .on( + eventType, + eventType2, + eventType3, + eventType4, + ) + .where((e) => e.cid == cid); + + late final _keyStrokeHandler = KeyStrokeHandler( + onStartTyping: startTyping, + onStopTyping: stopTyping, + ); + + // Whether sending typing events is allowed in the channel and by the user + // privacy settings. + bool get _canSendTypingEvents { + final currentUser = client.state.currentUser; + if (currentUser == null) return false; + + return canUseTypingEvents && currentUser.isTypingIndicatorsEnabled; + } + + /// Sends the [Event.typingStart] event and schedules a timer to invoke the + /// [Event.typingStop] event. + /// + /// This is meant to be called every time the user presses a key. + Future keyStroke([String? parentId]) async { + if (!_canSendTypingEvents) return; + + client.logger.info('KeyStroke received'); + return _keyStrokeHandler(parentId); + } + + /// Sends the [EventType.typingStart] event. + Future startTyping([String? parentId]) async { + if (!_canSendTypingEvents) return; + + client.logger.info('start typing'); + await sendEvent( + Event( + type: EventType.typingStart, + parentId: parentId, + ), + ); + } + + /// Sends the [EventType.typingStop] event. + Future stopTyping([String? parentId]) async { + if (!_canSendTypingEvents) return; + + client.logger.info('stop typing'); + await sendEvent( + Event( + type: EventType.typingStop, + parentId: parentId, + ), + ); + } + + /// Call this method to dispose the channel client. + void dispose() { + client.state.removeChannel('$cid'); + state?.dispose(); + state = null; + _muteExpirationTimer?.cancel(); + _keyStrokeHandler.cancel(); + } + + void _checkInitialized() { + if (_isInitialized) return; + + throw StateError( + "Channel $cid hasn't been initialized yet or has been disposed. " + 'Make sure to call .watch() or instantiate the client using ' + '[Channel.fromState]', + ); + } +} diff --git a/packages/stream_chat/lib/src/client/channel/channel_capability_check.dart b/packages/stream_chat/lib/src/client/channel/channel_capability_check.dart new file mode 100644 index 0000000000..c79245ac4f --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel_capability_check.dart @@ -0,0 +1,235 @@ +import '../../../stream_chat.dart'; + +/// Extension methods for checking channel capabilities on a Channel instance. +/// +/// These methods provide a convenient way to check if the current user has +/// specific capabilities in a channel. +extension ChannelCapabilityCheck on Channel { + /// True, if the current user can send a message to this channel. + bool get canSendMessage { + return ownCapabilities.contains(ChannelCapability.sendMessage); + } + + /// True, if the current user can send a reply to this channel. + bool get canSendReply { + return ownCapabilities.contains(ChannelCapability.sendReply); + } + + /// True, if the current user can send a message with restricted visibility. + bool get canSendRestrictedVisibilityMessage { + return ownCapabilities.contains( + ChannelCapability.sendRestrictedVisibilityMessage, + ); + } + + /// True, if the current user can send reactions. + bool get canSendReaction { + return ownCapabilities.contains(ChannelCapability.sendReaction); + } + + /// True, if the current user can attach links to messages. + bool get canSendLinks { + return ownCapabilities.contains(ChannelCapability.sendLinks); + } + + /// True, if the current user can attach files to messages. + bool get canCreateAttachment { + return ownCapabilities.contains(ChannelCapability.createAttachment); + } + + /// True, if the current user can freeze or unfreeze channel. + bool get canFreezeChannel { + return ownCapabilities.contains(ChannelCapability.freezeChannel); + } + + /// True, if the current user can enable or disable slow mode. + bool get canSetChannelCooldown { + return ownCapabilities.contains(ChannelCapability.setChannelCooldown); + } + + /// True, if the current user can leave channel (remove own membership). + bool get canLeaveChannel { + return ownCapabilities.contains(ChannelCapability.leaveChannel); + } + + /// True, if the current user can join channel (add own membership). + bool get canJoinChannel { + return ownCapabilities.contains(ChannelCapability.joinChannel); + } + + /// True, if the current user can pin a message. + bool get canPinMessage { + return ownCapabilities.contains(ChannelCapability.pinMessage); + } + + /// True, if the current user can delete any message from the channel. + bool get canDeleteAnyMessage { + return ownCapabilities.contains(ChannelCapability.deleteAnyMessage); + } + + /// True, if the current user can delete own messages from the channel. + bool get canDeleteOwnMessage { + return ownCapabilities.contains(ChannelCapability.deleteOwnMessage); + } + + /// True, if the current user can update any message in the channel. + bool get canUpdateAnyMessage { + return ownCapabilities.contains(ChannelCapability.updateAnyMessage); + } + + /// True, if the current user can update own messages in the channel. + bool get canUpdateOwnMessage { + return ownCapabilities.contains(ChannelCapability.updateOwnMessage); + } + + /// True, if the current user can use message search. + bool get canSearchMessages { + return ownCapabilities.contains(ChannelCapability.searchMessages); + } + + /// True, if the current user can send typing events. + @Deprecated('Use canUseTypingEvents instead') + bool get canSendTypingEvents { + if (canUseTypingEvents) return true; + return ownCapabilities.contains(ChannelCapability.sendTypingEvents); + } + + /// True, if the current user can upload message attachments. + bool get canUploadFile { + return ownCapabilities.contains(ChannelCapability.uploadFile); + } + + /// True, if the current user can delete channel. + bool get canDeleteChannel { + return ownCapabilities.contains(ChannelCapability.deleteChannel); + } + + /// True, if the current user can update channel data. + bool get canUpdateChannel { + return ownCapabilities.contains(ChannelCapability.updateChannel); + } + + /// True, if the current user can update channel members. + bool get canUpdateChannelMembers { + return ownCapabilities.contains(ChannelCapability.updateChannelMembers); + } + + /// True, if the current user can update thread data. + bool get canUpdateThread { + return ownCapabilities.contains(ChannelCapability.updateThread); + } + + /// True, if the current user can quote a message. + bool get canQuoteMessage { + return ownCapabilities.contains(ChannelCapability.quoteMessage); + } + + /// True, if the current user can ban channel members. + bool get canBanChannelMembers { + return ownCapabilities.contains(ChannelCapability.banChannelMembers); + } + + /// True, if the current user can flag a message. + bool get canFlagMessage { + return ownCapabilities.contains(ChannelCapability.flagMessage); + } + + /// True, if the current user can mute a channel. + bool get canMuteChannel { + return ownCapabilities.contains(ChannelCapability.muteChannel); + } + + /// True, if the current user can send custom events. + bool get canSendCustomEvents { + return ownCapabilities.contains(ChannelCapability.sendCustomEvents); + } + + /// True, if the current user has read events capability. + @Deprecated('Use canUseReadReceipts instead') + bool get canReceiveReadEvents => canUseReadReceipts; + + /// True, if the current user has read events capability. + bool get canUseReadReceipts { + return ownCapabilities.contains(ChannelCapability.readEvents); + } + + /// True, if unread counts for this channel should be tracked locally, + /// on-device, rather than relying on the server. + /// + /// This is the case when [StreamChatClient.isLocalUnreadCountEnabled] is + /// enabled and the channel doesn't support read receipts (for example, + /// livestream channel types that disable read events). Channels that + /// support read receipts always rely on server-driven unread counts. + bool get usesLocalUnreadCount { + return client.isLocalUnreadCountEnabled && !canUseReadReceipts; + } + + /// True, if the current user has connect events capability. + bool get canReceiveConnectEvents { + return ownCapabilities.contains(ChannelCapability.connectEvents); + } + + /// True, if the current user can send and receive typing events. + bool get canUseTypingEvents { + return ownCapabilities.contains(ChannelCapability.typingEvents); + } + + /// True, if channel slow mode is active. + bool get isInSlowMode { + return ownCapabilities.contains(ChannelCapability.slowMode); + } + + /// True, if the current user is allowed to post messages as usual even if the + /// channel is in slow mode. + bool get canSkipSlowMode { + return ownCapabilities.contains(ChannelCapability.skipSlowMode); + } + + /// True, if the current user can create a poll. + bool get canSendPoll { + return ownCapabilities.contains(ChannelCapability.sendPoll); + } + + /// True, if the current user can vote in a poll. + bool get canCastPollVote { + return ownCapabilities.contains(ChannelCapability.castPollVote); + } + + /// True, if the current user can query poll votes. + bool get canQueryPollVotes { + return ownCapabilities.contains(ChannelCapability.queryPollVotes); + } + + /// True, if the current user has delivery events capability. + bool get canUseDeliveryReceipts { + return ownCapabilities.contains(ChannelCapability.deliveryEvents); + } + + /// True, if the current user can share location in the channel. + bool get canShareLocation { + return ownCapabilities.contains(ChannelCapability.shareLocation); + } + + /// True, if the current user can send an "@channel" mention that notifies + /// all channel members. + bool get canNotifyChannel { + return ownCapabilities.contains(ChannelCapability.notifyChannel); + } + + /// True, if the current user can send an "@here" mention that notifies all + /// online channel members. + bool get canNotifyHere { + return ownCapabilities.contains(ChannelCapability.notifyHere); + } + + /// True, if the current user can mention one or more roles in a message. + bool get canNotifyRole { + return ownCapabilities.contains(ChannelCapability.notifyRole); + } + + /// True, if the current user can mention one or more user groups in a + /// message. + bool get canNotifyGroup { + return ownCapabilities.contains(ChannelCapability.notifyGroup); + } +} diff --git a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart new file mode 100644 index 0000000000..3e1b3a3624 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart @@ -0,0 +1,2155 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../../stream_chat.dart'; +import '../../core/util/utils.dart'; +import '../retry_queue.dart'; + +/// The class that handles the state of the channel listening to the events. +class ChannelClientState { + /// Creates a new instance listening to events and updating the state. + ChannelClientState( + this._channel, + ChannelState channelState, + ) { + _retryQueue = RetryQueue( + channel: _channel, + logger: _client.detachedLogger( + '🔄 (${generateHash([_channel.cid])})', + ), + ); + + _channelStateController = BehaviorSubject.seeded(channelState); + // Update the persistence storage with the seeded channel state. + _debouncedUpdatePersistenceChannelState.call([channelState]); + + // region TYPING EVENTS + _listenTypingEvents(); + // endregion + + // region MESSAGE EVENTS + _listenMessageNew(); + _listenMessageDeleted(); + _listenMessageUpdated(); + // endregion + + // region DRAFT EVENTS + _listenDraftUpdated(); + _listenDraftDeleted(); + // endregion + + // region REACTION EVENTS + _listenReactionNew(); + _listenReactionUpdated(); + _listenReactionDeleted(); + // endregion + + // region POLL EVENTS + _listenPollCreated(); + _listenPollUpdated(); + _listenPollClosed(); + _listenPollAnswerCasted(); + _listenPollVoteCasted(); + _listenPollVoteChanged(); + _listenPollAnswerRemoved(); + _listenPollVoteRemoved(); + // endregion + + // region READ EVENTS + _listenReadEvents(); + // endregion + + // region CHANNEL EVENTS + _listenChannelTruncated(); + _listenChannelUpdated(); + _listenChannelCounts(); + // endregion + + // region MEMBER EVENTS + _listenMemberAdded(); + _listenMemberRemoved(); + _listenMemberUpdated(); + _listenMemberBanned(); + _listenMemberUnbanned(); + _listenUserMessagesDeleted(); + // endregion + + // region USER WATCHING EVENTS + _listenUserStartWatching(); + _listenUserStopWatching(); + // endregion + + // region REMINDER EVENTS + _listenReminderCreated(); + _listenReminderUpdated(); + _listenReminderDeleted(); + // endregion + + // region LOCATION EVENTS + _listenLocationShared(); + _listenLocationUpdated(); + _listenLocationExpired(); + // endregion + + _startCleaningStaleTypingEvents(); + + _startCleaningStalePinnedMessages(); + + _startCleaningExpiredLocations(); + + _listenChannelPushPreferenceUpdated(); + + final persistenceClient = _client.chatPersistenceClient; + persistenceClient + ?.getChannelThreads(_channel.cid!) + .then((threads) { + // Load all the threads for the channel from the offline storage. + if (threads.isNotEmpty) _threads = threads; + }) + .then((_) => retryFailedMessages()); + } + + final Channel _channel; + StreamChatClient get _client => _channel.client; + final _subscriptions = CompositeSubscription(); + + void _listenMemberAdded() { + _subscriptions.add( + _channel.on(EventType.memberAdded).listen((Event e) { + final member = e.member!; + final existingMembers = channelState.members ?? []; + + updateChannelState( + channelState.copyWith( + members: [...existingMembers, member], + ), + ); + }), + ); + } + + void _listenMemberRemoved() { + _subscriptions.add( + _channel.on(EventType.memberRemoved).listen((Event e) { + final user = e.user!; + final existingRead = channelState.read ?? []; + final existingMembers = channelState.members ?? []; + + updateChannelState( + channelState.copyWith( + read: [...existingRead.where((r) => r.user.id != user.id)], + members: [...existingMembers.where((m) => m.userId != user.id)], + ), + ); + }), + ); + } + + void _listenMemberUpdated() { + _subscriptions + // Listen to events containing member users + ..add( + _channel.on().listen( + (event) { + final user = event.user; + if (user == null) return; + + final existingMembers = [...?channelState.members]; + final existingMembership = channelState.membership; + + // Return if the user is not a existing member of the channel. + if (!existingMembers.any((m) => m.userId == user.id)) return; + + Member? maybeUpdateMemberUser(Member? existingMember) { + if (existingMember == null) return null; + if (existingMember.userId == user.id) { + return existingMember.copyWith(user: user); + } + return existingMember; + } + + updateChannelState( + channelState.copyWith( + membership: maybeUpdateMemberUser(existingMembership), + members: [...existingMembers.map(maybeUpdateMemberUser).nonNulls], + ), + ); + }, + ), + ) + // Listen to member updated events. + ..add( + _channel.on(EventType.memberUpdated).listen( + (Event e) { + final member = e.member!; + final existingMembers = channelState.members ?? []; + final existingMembership = channelState.membership; + + Member? maybeUpdateMember(Member? existingMember) { + if (existingMember == null) return null; + if (existingMember.userId == member.userId) return member; + return existingMember; + } + + updateChannelState( + channelState.copyWith( + membership: maybeUpdateMember(existingMembership), + members: [...existingMembers.map(maybeUpdateMember).nonNulls], + ), + ); + }, + ), + ); + } + + void _listenChannelUpdated() { + _subscriptions.add( + _channel.on(EventType.channelUpdated).listen((Event e) { + final channel = e.channel!; + updateChannelState( + channelState.copyWith( + channel: channelState.channel?.merge(channel), + members: channel.members, + ), + ); + }), + ); + } + + // Most channel events carry the channel's member and message counts as + // event metadata, reflecting the authoritative values after the change. + // Applying them keeps the counts fresh for the whole session instead of + // only right after a `query` / `watch`. + void _listenChannelCounts() { + _subscriptions.add( + _channel.on().listen( + (Event e) { + final memberCount = e.channelMemberCount; + final messageCount = e.channelMessageCount; + if (memberCount == null && messageCount == null) return; + + updateChannelState( + channelState.copyWith( + channel: channelState.channel?.copyWith( + memberCount: memberCount, + messageCount: messageCount, + ), + ), + ); + }, + ), + ); + } + + void _listenChannelTruncated() { + _subscriptions.add( + _channel.on(EventType.channelTruncated, EventType.notificationChannelTruncated).listen((event) async { + final channel = event.channel!; + await _client.chatPersistenceClient?.deleteMessageByCid(channel.cid); + truncate(); + if (event.message != null) { + updateMessage(event.message!); + } + }), + ); + } + + void _listenMemberBanned() { + _subscriptions.add( + _channel + .on(EventType.userBanned) + .where((it) => it.cid != null) // filters channel ban from app ban + .listen( + (event) async { + final user = event.user!; + final member = await _channel + .queryMembers(filter: Filter.equal('id', user.id)) + .then((it) => it.members.first); + + _updateMember(member); + }, + ), + ); + } + + void _listenUserStartWatching() { + _subscriptions.add( + _channel.on(EventType.userWatchingStart).listen((event) { + final watcher = event.user; + if (watcher != null) { + final existingWatchers = channelState.watchers; + updateChannelState( + channelState.copyWith( + watchers: [ + watcher, + ...?existingWatchers?.where((user) => user.id != watcher.id), + ], + watcherCount: event.watcherCount, + ), + ); + } + }), + ); + } + + void _listenUserStopWatching() { + _subscriptions.add( + _channel.on(EventType.userWatchingStop).listen((event) { + final watcher = event.user; + if (watcher != null) { + final existingWatchers = channelState.watchers ?? const []; + _channelState = channelState.copyWith( + watchers: existingWatchers.where((user) => user.id != watcher.id).toList(), + watcherCount: event.watcherCount, + ); + } + }), + ); + } + + void _listenMemberUnbanned() { + _subscriptions.add( + _channel + .on(EventType.userUnbanned) + .where((it) => it.cid != null) // filters channel ban from app ban + .listen( + (event) async { + final user = event.user!; + final member = await _channel + .queryMembers(filter: Filter.equal('id', user.id)) + .then((it) => it.members.first); + + _updateMember(member); + }, + ), + ); + } + + void _updateMember(Member member) { + final currentMembers = [...members]; + final memberIndex = currentMembers.indexWhere( + (m) => m.userId == member.userId, + ); + + if (memberIndex == -1) return; + currentMembers[memberIndex] = member; + + updateChannelState( + channelState.copyWith( + members: currentMembers, + ), + ); + } + + /// Flag which indicates if [ChannelClientState] contain latest/recent messages or not. + /// + /// This flag should be managed by UI sdks. + /// + /// When false, any new message received by WebSocket event + /// [EventType.messageNew] will not be pushed on to message list. + bool get isUpToDate => _isUpToDateController.value; + + set isUpToDate(bool isUpToDate) => _isUpToDateController.safeAdd(isUpToDate); + + /// [isUpToDate] flag count as a stream. + Stream get isUpToDateStream => _isUpToDateController.stream; + final _isUpToDateController = BehaviorSubject.seeded(true); + + /// The retry queue associated to this channel. + late final RetryQueue _retryQueue; + + /// Queues [message] for another send attempt. + @internal + void scheduleRetry(Message message) => _retryQueue.add([message]); + + /// Retry failed message. + Future retryFailedMessages() async { + final allMessages = [...messages, ...threads.values.flattened]; + final failedMessages = allMessages.where((it) => it.state.isFailed); + + if (failedMessages.isEmpty) return; + _retryQueue.add(failedMessages); + } + + Message? _findPollMessage(String pollId) { + final message = messages.firstWhereOrNull((it) => it.pollId == pollId); + if (message != null) return message; + + final threadMessage = threads.values.flattened.firstWhereOrNull((it) { + return it.pollId == pollId; + }); + + return threadMessage; + } + + void _listenPollCreated() { + _subscriptions.add( + _channel.on(EventType.pollCreated).listen((event) { + final message = event.message; + if (message == null || message.poll == null) return; + + return addNewMessage(message); + }), + ); + } + + void _listenPollUpdated() { + _subscriptions.add( + _channel.on(EventType.pollUpdated).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final ownVotesAndAnswers = oldPoll?.ownVotesAndAnswers ?? eventPoll.ownVotesAndAnswers; + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollClosed() { + _subscriptions.add( + _channel.on(EventType.pollClosed).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + final poll = oldPoll?.copyWith(isClosed: true) ?? eventPoll; + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollAnswerCasted() { + _subscriptions.add( + _channel.on(EventType.pollAnswerCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = { + for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, + eventPollVote.id!: eventPollVote, + }; + + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + latestAnswers: [...latestAnswers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollVoteCasted() { + _subscriptions.add( + _channel.on(EventType.pollVoteCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollAnswerRemoved() { + _subscriptions.add( + _channel.on(EventType.pollAnswerRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = { + for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, + }..remove(eventPollVote.id); + + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); + + final poll = eventPoll.copyWith( + latestAnswers: [...latestAnswers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollVoteRemoved() { + _subscriptions.add( + _channel.on(EventType.pollVoteRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenPollVoteChanged() { + _subscriptions.add( + _channel.on(EventType.pollVoteChanged).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); + } + + void _listenDraftUpdated() { + _subscriptions.add( + _channel.on(EventType.draftUpdated).listen((event) { + final draft = event.draft; + if (draft == null) return; + + return updateDraft(draft); + }), + ); + } + + void _listenDraftDeleted() { + _subscriptions.add( + _channel.on(EventType.draftDeleted).listen((event) { + final draft = event.draft; + if (draft == null) return; + + return deleteDraft(draft); + }), + ); + } + + void _listenReminderCreated() { + _subscriptions.add( + _channel.on(EventType.reminderCreated).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + updateReminder(reminder); + }), + ); + } + + void _listenReminderUpdated() { + _subscriptions.add( + _channel.on(EventType.reminderUpdated).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + updateReminder(reminder); + }), + ); + } + + void _listenReminderDeleted() { + _subscriptions.add( + _channel.on(EventType.reminderDeleted).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + deleteReminder(reminder); + }), + ); + } + + /// Updates the [reminder] of the message if it exists. + void updateReminder(MessageReminder reminder) { + final messageId = reminder.messageId; + // TODO: Improve once we have support for parentId in reminders. + for (final message in [...messages, ...threads.values.flattened]) { + if (message.id == messageId) { + return updateMessage( + message.copyWith(reminder: reminder), + ); + } + } + } + + /// Deletes the [reminder] of the message if it exists. + void deleteReminder(MessageReminder reminder) { + final messageId = reminder.messageId; + // TODO: Improve once we have support for parentId in reminders. + for (final message in [...messages, ...threads.values.flattened]) { + if (message.id == messageId) { + return updateMessage( + message.copyWith(reminder: null), + ); + } + } + } + + Message? _findLocationMessage(String id) { + final message = messages.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + if (message != null) return message; + + final threadMessage = threads.values.flattened.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + return threadMessage; + } + + void _listenLocationShared() { + _subscriptions.add( + _channel.on(EventType.locationShared).listen((event) { + final message = event.message; + if (message == null || message.sharedLocation == null) return; + + return addNewMessage(message); + }), + ); + } + + void _listenLocationUpdated() { + _subscriptions.add( + _channel.on(EventType.locationUpdated).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + + void _listenLocationExpired() { + _subscriptions.add( + _channel.on(EventType.locationExpired).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + + void _listenReactionDeleted() { + _subscriptions.add( + _channel.on(EventType.reactionDeleted).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => message.deleteMyReaction( + reactionType: eventReaction.type, + ), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenReactionNew() { + _subscriptions.add( + _channel.on(EventType.reactionNew).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => message.addMyReaction(eventReaction), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenReactionUpdated() { + _subscriptions.add( + _channel.on(EventType.reactionUpdated).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => + // reaction.updated is only called if enforce_unique is true + message.addMyReaction(eventReaction, enforceUnique: true), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenMessageUpdated() { + _subscriptions.add( + _channel.on(EventType.messageUpdated).listen((event) { + final message = event.message; + if (message == null) return; + + return updateMessage(message, upsert: false); + }), + ); + } + + void _listenMessageDeleted() { + _subscriptions.add( + _channel.on(EventType.messageDeleted).listen((event) { + final hardDelete = event.hardDelete ?? false; + + final message = event.message!.copyWith( + // TODO: Remove once deletedForMe is properly enriched on the backend. + deletedForMe: event.deletedForMe, + ); + + // Decrement the locally-tracked unread count for hard-deleted + // messages that would have counted as unread. Soft-deleted messages + // keep their slot. Only applies to channels that track unread counts + // locally (see [Channel.usesLocalUnreadCount]) — server-driven + // channels get corrected counts from server read events instead. + if (hardDelete && _channel.usesLocalUnreadCount && MessageRules.canCountAsUnread(message, _channel)) { + unreadCount = math.max(0, unreadCount - 1); + } + + return deleteMessage(message, hardDelete: hardDelete); + }), + ); + } + + void _listenMessageNew() { + _subscriptions.add( + _channel + .on( + EventType.messageNew, + EventType.notificationMessageNew, + ) + .listen((event) { + final message = event.message; + if (message == null) return; + + addNewMessage(message); + + // Only message.new carries a reliable watcher count; + // notification.message_new targets non-watchers and reports 0. + if (event.watcherCount case final watcherCount? when event.type == EventType.messageNew) { + updateChannelState( + channelState.copyWith(watcherCount: watcherCount), + ); + } + }), + ); + } + + /// Adds a new message to the channel state and updates the unread count. + void addNewMessage(Message message) { + final isThreadMessage = message.parentId != null; + final isNotShownInChannel = message.showInChannel != true; + final isThreadOnlyMessage = isThreadMessage && isNotShownInChannel; + + // Only add the message if the channel is upToDate or if the message is + // a thread-only message. + if (isUpToDate || isThreadOnlyMessage) updateMessage(message); + + // Otherwise, check if we can count the message as unread. + if (MessageRules.canCountAsUnread(message, _channel)) { + unreadCount += 1; // Increment unread count + } + + _client.channelDeliveryReporter.submitForDelivery([_channel]); + } + + /// Updates the [read] in the state if it exists. Adds it otherwise. + void updateRead([Iterable? read]) { + final existingReads = channelState.read ?? const []; + final updatedReads = existingReads.merge( + read, + key: (read) => read.user.id, + ); + + updateChannelState( + channelState.copyWith( + read: updatedReads.toList(), + ), + ); + } + + /// Updates the [draft] in the channel state or the message if it exists. + void updateDraft(Draft draft) { + if (draft.parentId case final parentId?) { + for (final message in messages) { + if (message.id == parentId) { + return updateMessage(message.copyWith(draft: draft)); + } + } + } + + updateChannelState( + channelState.copyWith( + draft: draft, + ), + ); + } + + /// Deletes the [draft] from the state if it exists. + void deleteDraft(Draft draft) async { + // Delete the draft from the persistence client. + await _client.chatPersistenceClient?.deleteDraftMessageByCid( + draft.channelCid, + parentId: draft.parentId, + ); + + if (draft.parentId case final parentId?) { + for (final message in messages) { + if (message.id == parentId) { + return updateMessage( + message.copyWith(draft: null), + ); + } + } + } + + updateChannelState( + channelState.copyWith( + draft: null, + ), + ); + } + + /// Updates the [message] in the state. + /// + /// Reconciles via `Message.updateWith`, so locally-known enrichment + /// (poll, sharedLocation, ownReactions, nested quotedMessage) is + /// preserved when [message] omits those fields. Use [replaceMessage] + /// for paths that need a strict overwrite. + /// + /// When [upsert] is `true` (the default) and [message] isn't already in + /// the state, it's added. When `false`, an unknown [message] is skipped + /// and the state is left unchanged; only a message already loaded in the + /// state is updated. + void updateMessage(Message message, {bool upsert = true}) => _updateMessages([message], upsert: upsert); + + /// Replaces the [message] in the state if it exists, no-op otherwise. + /// + /// Unlike [updateMessage], this does **not** merge with the existing + /// state — [message] is used as-is. Useful for local rollbacks of an + /// optimistic update, where the caller has the full prior snapshot and + /// doesn't want the merge falling back to the optimistic values. + void replaceMessage(Message message) => _updateMessages([message], update: _replaceUpdate); + + // Default `update` for [_updateMessages]: merge incoming with the + // locally-known message via `Message.updateWith`, preserving enrichment + // the server may strip on partial payloads. + static Message _mergeUpdate(Message original, Message updated) => original.updateWith(updated); + + // Replace `update` for [_updateMessages]: take the incoming as-is. Used + // by local rollback paths. + static Message _replaceUpdate(Message _, Message updated) => updated; + + /// Cleans up all the stale error messages which requires no action. + void cleanUpStaleErrorMessages() { + final errorMessages = messages.where((message) { + return message.isError && !message.isBounced; + }); + + if (errorMessages.isEmpty) return; + return _removeMessages(errorMessages); + } + + /// Remove a [message] from this [channelState]. + void removeMessage(Message message) => _removeMessages([message]); + + /// Removes/Updates the [message] based on the [hardDelete] value. + void deleteMessage(Message message, {bool hardDelete = false}) { + return _deleteMessages([message], hardDelete: hardDelete); + } + + void _listenReadEvents() { + _subscriptions + ..add( + _channel.on(EventType.messageRead, EventType.notificationMarkRead).listen( + (event) { + // Skip handling the event if delivered for a thread + if (event.thread != null) return; + + final user = event.user; + if (user == null) return; + + final currentRead = userReadOf(userId: user.id); + + final updatedRead = Read( + user: user, + lastRead: event.createdAt, + unreadMessages: 0, // Reset unread count + lastReadMessageId: event.lastReadMessageId, + // Preserve delivery info as it's not part of the read event. + lastDeliveredAt: currentRead?.lastDeliveredAt, + lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, + ); + + updateRead([updatedRead]); + + // If the read event is from the current user, reconcile the + // channel delivery status with the updated read state. + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) { + _client.channelDeliveryReporter.reconcileDelivery([_channel]); + } + }, + ), + ) + ..add( + _channel.on(EventType.notificationMarkUnread).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentRead = userReadOf(userId: user.id); + + final updatedRead = Read( + user: user, + lastRead: event.lastReadAt!, + unreadMessages: event.unreadMessages, + lastReadMessageId: event.lastReadMessageId, + // Preserve delivery info as it's not part of the read event. + lastDeliveredAt: currentRead?.lastDeliveredAt, + lastDeliveredMessageId: currentRead?.lastDeliveredMessageId, + ); + + return updateRead([updatedRead]); + }, + ), + ) + ..add( + _channel.on(EventType.messageDelivered).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentRead = userReadOf(userId: user.id); + final never = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + final updatedRead = Read( + user: user, + lastDeliveredAt: event.lastDeliveredAt, + lastDeliveredMessageId: event.lastDeliveredMessageId, + // Preserve read info as it's not part of the delivery event. + lastRead: currentRead?.lastRead ?? never, + unreadMessages: currentRead?.unreadMessages, + lastReadMessageId: currentRead?.lastReadMessageId, + ); + + updateRead([updatedRead]); + + // If the delivered event is from the current user, reconcile + // the channel delivery with the updated read state. + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) { + _client.channelDeliveryReporter.reconcileDelivery([_channel]); + } + }, + ), + ); + } + + /// Channel message list. + List get messages => _channelState.messages ?? []; + + /// Channel message list as a stream. + Stream> get messagesStream => + channelStateStream.map((cs) => cs.messages ?? []).distinct(const ListEquality().equals); + + /// Channel pinned message list. + List get pinnedMessages => _channelState.pinnedMessages ?? []; + + /// Channel pinned message list as a stream. + Stream> get pinnedMessagesStream => + channelStateStream.map((cs) => cs.pinnedMessages ?? []).distinct(const ListEquality().equals); + + /// Channel pending message list. + List get pendingMessages => _channelState.pendingMessages ?? []; + + /// Channel pending message list as a stream. + Stream> get pendingMessagesStream => + channelStateStream.map((cs) => cs.pendingMessages ?? []).distinct(const ListEquality().equals); + + /// Get channel last message. + Message? get lastMessage => messages.lastOrNull; + + /// Get channel last message as a stream. + Stream get lastMessageStream { + return messagesStream.map((messages) => messages.lastOrNull); + } + + /// Channel members list. + List get members => + (_channelState.members ?? []).map((e) => e.copyWith(user: _client.state.users[e.user!.id])).toList(); + + /// Channel members list as a stream. + Stream> get membersStream => + CombineLatestStream.combine2?, Map, List>( + channelStateStream.map((cs) => cs.members), + _client.state.usersStream, + (members, users) => [...?members?.map((e) => e!.copyWith(user: users[e.user!.id]))], + ).distinct(const ListEquality().equals); + + /// Channel watcher count. + int? get watcherCount => _channelState.watcherCount; + + /// Channel watcher count as a stream. + Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount).distinct(); + + /// Channel watchers list. + List get watchers => (_channelState.watchers ?? []).map((e) => _client.state.users[e.id] ?? e).toList(); + + /// Channel watchers list as a stream. + Stream> get watchersStream => CombineLatestStream.combine2?, Map, List>( + channelStateStream.map((cs) => cs.watchers), + _client.state.usersStream, + (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], + ).distinct(const ListEquality().equals); + + /// Channel active live locations. + List get activeLiveLocations { + return _channelState.activeLiveLocations ?? []; + } + + /// Channel active live locations as a stream. + Stream> get activeLiveLocationsStream => + channelStateStream.map((cs) => cs.activeLiveLocations ?? []).distinct(const ListEquality().equals); + + /// Channel draft. + Draft? get draft => _channelState.draft; + + /// Channel draft as a stream. + Stream get draftStream { + return channelStateStream.map((cs) => cs.draft).distinct(); + } + + /// Channel member for the current user. + Member? get currentUserMember => members.firstWhereOrNull( + (m) => m.user?.id == _client.state.currentUser?.id, + ); + + /// Channel role for the current user + String? get currentUserChannelRole => currentUserMember?.channelRole; + + /// Channel read list. + List get read => _channelState.read ?? []; + + /// Channel read list as a stream. + Stream> get readStream => + channelStateStream.map((cs) => cs.read ?? []).distinct(const ListEquality().equals); + + /// Channel read for the logged in user. + Read? get currentUserRead { + final currentUser = _client.state.currentUser; + return userReadOf(userId: currentUser?.id); + } + + /// Channel read for the logged in user as a stream. + /// + /// Re-subscribes only when the user id actually changes; null still + /// propagates downstream so consumers see the logged-out transition. + Stream get currentUserReadStream { + final currentUserId = _client.state.currentUserStream.map((it) => it?.id).distinct(); + return currentUserId.switchMap((id) => userReadStreamOf(userId: id)).distinct(); + } + + /// Unread count getter as a stream. + Stream get unreadCountStream => currentUserReadStream.map((read) => read?.unreadMessages ?? 0).distinct(); + + /// Unread count getter. + int get unreadCount => currentUserRead?.unreadMessages ?? 0; + + /// Setter for unread count. + set unreadCount(int count) { + final currentUser = _client.state.currentUser; + if (currentUser == null) return; + + var existingUserRead = currentUserRead; + if (existingUserRead == null) { + final lastMessageAt = _channelState.channel?.lastMessageAt; + existingUserRead = Read( + user: currentUser, + lastRead: lastMessageAt ?? DateTime.now(), + ); + } + + return updateRead([existingUserRead.copyWith(unreadMessages: count)]); + } + + /// Marks the channel as read locally, without making a network request. + /// + /// Used for channels that track unread counts locally (see + /// [Channel.usesLocalUnreadCount]), since the server rejects the mark-read + /// endpoint for channels that have read events disabled. + /// + /// [messageId] only sets the resulting [Read.lastReadMessageId]; it does not + /// narrow which messages stay unread. The count always drops to zero and + /// [Read.lastRead] is always `now`, so messages newer than [messageId] are + /// marked read as well. This differs from the server, which recomputes the + /// count as the number of messages after [messageId], and from + /// [markUnreadLocally], which does recompute from the locally-known + /// messages. Callers that need a partial boundary should use + /// [markUnreadLocally] instead. + void markReadLocally({String? messageId}) { + final currentUser = _client.state.currentUser; + if (currentUser == null) return; + + final now = DateTime.now(); + final lastReadMessageId = messageId ?? messages.lastOrNull?.id; + + final existingUserRead = currentUserRead; + updateRead([ + Read( + user: currentUser, + lastRead: now, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: existingUserRead?.lastDeliveredAt, + lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId, + ), + ]); + + // Read supersedes delivered, so drop any pending delivery candidate the + // new read boundary just made ineligible. `delivery_events` is configured + // independently of `read_events`, so a channel tracking unread counts + // locally can still have delivery receipts enabled. Mirrors what the + // `message.read` event listener does for server-driven channels. + _client.channelDeliveryReporter.reconcileDelivery([_channel]); + } + + /// Marks the channel as unread locally, without making a network request. + /// + /// [lastRead] and [lastReadMessageId] define the new read boundary: any + /// locally-known message that is still eligible per + /// [MessageRules.canCountAsUnread] once this boundary is applied is counted + /// as unread. + /// + /// Used for channels that track unread counts locally (see + /// [Channel.usesLocalUnreadCount]), since the server rejects the + /// mark-unread endpoint for channels that have read events disabled. + void markUnreadLocally({ + required DateTime lastRead, + String? lastReadMessageId, + }) { + final currentUser = _client.state.currentUser; + if (currentUser == null) return; + + final existingUserRead = currentUserRead; + + // Apply the new read boundary first so `MessageRules.canCountAsUnread` + // (which reads `channel.state?.currentUserRead`) evaluates against it. + updateRead([ + Read( + user: currentUser, + lastRead: lastRead, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: existingUserRead?.lastDeliveredAt, + lastDeliveredMessageId: existingUserRead?.lastDeliveredMessageId, + ), + ]); + + // Recompute the unread count from the locally-known messages now that + // the boundary above is in effect. + final unread = messages.where((it) => MessageRules.canCountAsUnread(it, _channel)).length; + + unreadCount = unread; + } + + /// Counts the number of unread messages mentioning the current user. + /// + /// **NOTE**: The method relies on the [Channel.messages] list and doesn't do + /// any API call. Therefore, the count might be not reliable as it relies on + /// the local data. + int countUnreadMentions() { + final currentUserId = _client.state.currentUser?.id; + + var count = 0; + for (final message in messages) { + if (!MessageRules.canCountAsUnread(message, _channel)) continue; + if (!message.mentionedUsers.any((it) => it.id == currentUserId)) continue; + + count++; + } + + return count; + } + + /// Delete all channel messages. + void truncate() { + _channelState = _channelState.copyWith( + messages: [], + ); + } + + /// Drops the oldest messages, keeping at most [maxMessages]. + /// + /// No-op when [maxMessages] is non-positive, when the current count is + /// already within the limit, or when [isUpToDate] is `false`. + /// + /// Prefer `StreamChannel.pruneOldest` when a [StreamChannel] is present: + /// it also resets the widget-layer "top reached" marker so top-pagination + /// can resume. Calling this directly leaves that marker untouched. + void pruneOldest(int maxMessages) { + if (maxMessages <= 0) return; + if (!isUpToDate) return; + + final current = messages; + if (current.length <= maxMessages) return; + + final pruned = current.sublist(current.length - maxMessages); + _channelState = _channelState.copyWith(messages: pruned); + } + + /// Update channelState with updated information. + void updateChannelState(ChannelState updatedState) { + final newMessages = messages.mergeSorted( + updatedState.messages, + key: (message) => message.id, + update: _mergeUpdate, + compare: _sortByCreatedAt, + ); + + final watchers = _channelState.watchers ?? const []; + final newWatchers = watchers.merge( + updatedState.watchers, + key: (watcher) => watcher.id, + ); + + final reads = _channelState.read ?? const []; + final newReads = reads.merge( + updatedState.read, + key: (read) => read.user.id, + ); + + _channelState = _channelState.copyWith( + messages: newMessages, + channel: _channelState.channel?.merge(updatedState.channel), + watchers: newWatchers.toList(), + watcherCount: updatedState.watcherCount, + members: updatedState.members, + membership: updatedState.membership, + read: newReads.toList(), + draft: updatedState.draft, + pinnedMessages: updatedState.pinnedMessages, + pendingMessages: updatedState.pendingMessages, + pushPreferences: updatedState.pushPreferences, + activeLiveLocations: updatedState.activeLiveLocations, + ); + } + + /// Applies a [remoteState] received from the server or offline storage + /// (e.g. a `query`/`watch` response), merging it into local state. + /// + /// Unlike [updateChannelState], this preserves the current user's + /// locally-tracked read state for channels that track unread counts + /// on-device (see [Channel.usesLocalUnreadCount]) — their `lastRead`, + /// `lastReadMessageId`, and `unreadMessages` are kept as-is instead of + /// being overwritten by the remote payload; only delivery fields are + /// still applied from it. + /// + /// Call this instead of [updateChannelState] whenever [remoteState] + /// genuinely comes from the network or offline storage. + void updateChannelStateFromServer(ChannelState remoteState) { + updateChannelState(_preserveLocalUnreadState(remoteState)); + } + + /// Rewrites the current user's [Read] in [remoteState], if present, to + /// keep the locally-tracked `lastRead` / `lastReadMessageId` / + /// `unreadMessages` while still adopting the remote delivery fields. + /// + /// No-op unless [Channel.usesLocalUnreadCount] is enabled and a local read + /// already exists for the current user. + ChannelState _preserveLocalUnreadState(ChannelState remoteState) { + if (!_channel.usesLocalUnreadCount) return remoteState; + + final localRead = currentUserRead; + final remoteReads = remoteState.read; + if (localRead == null || remoteReads == null) return remoteState; + + final currentUserId = localRead.user.id; + final preservedReads = remoteReads.map((read) { + if (read.user.id != currentUserId) return read; + return localRead.copyWith( + lastDeliveredAt: read.lastDeliveredAt, + lastDeliveredMessageId: read.lastDeliveredMessageId, + ); + }); + + return remoteState.copyWith(read: preservedReads.toList()); + } + + int _sortByCreatedAt(Message a, Message b) => a.createdAt.compareTo(b.createdAt); + + /// The channel state related to this client. + ChannelState get _channelState => _channelStateController.value; + + /// The channel state related to this client as a stream. + Stream get channelStateStream => _channelStateController.stream; + + /// The channel state related to this client. + ChannelState get channelState => _channelStateController.value; + late BehaviorSubject _channelStateController; + + late final _debouncedUpdatePersistenceChannelState = debounce( + (ChannelState state) { + final persistenceClient = _client.chatPersistenceClient; + return persistenceClient?.updateChannelState(state); + }, + const Duration(seconds: 1), + ); + + set _channelState(ChannelState v) { + _channelStateController.safeAdd(v); + _debouncedUpdatePersistenceChannelState.call([v]); + } + + late final _debouncedUpdatePersistenceChannelThreads = debounce( + (Map> threads) async { + final channelCid = _channel.cid; + if (channelCid == null) return; + + final persistenceClient = _client.chatPersistenceClient; + return persistenceClient?.updateChannelThreads(channelCid, threads); + }, + const Duration(seconds: 1), + ); + + /// The channel threads related to this channel. + Map> get threads => {..._threadsController.value}; + + /// The channel threads related to this channel as a stream. + Stream>> get threadsStream => _threadsController; + final _threadsController = BehaviorSubject.seeded(>{}); + set _threads(Map> threads) { + _threadsController.safeAdd(threads); + _debouncedUpdatePersistenceChannelThreads.call([threads]); + } + + /// Clears all the replies in the thread identified by [parentId]. + void clearThread(String parentId) { + final updatedThreads = { + ...threads, + parentId: [], + }; + + _threads = updatedThreads; + } + + /// Update threads with updated information about messages. + void updateThreadInfo(String parentId, List messages) { + final updatedThreads = {...threads}; + + final threadMessages = updatedThreads[parentId] ?? []; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages.where((it) => it.id != parentId), + ); + + // Update the thread with the modified message list. + updatedThreads[parentId] = updatedThreadMessages.toList(); + + _threads = updatedThreads; + } + + Draft? _getThreadDraft(String parentId, List? messages) { + return messages?.firstWhereOrNull((it) => it.id == parentId)?.draft; + } + + /// Draft for a specific thread identified by [parentId]. + Draft? threadDraft(String parentId) => _getThreadDraft(parentId, messages); + + /// Stream of draft for a specific thread identified by [parentId]. + /// + /// This stream emits a new value whenever the draft associated with the + /// specified thread is updated or removed. + Stream threadDraftStream(String parentId) => + channelStateStream.map((cs) => _getThreadDraft(parentId, cs.messages)).distinct(); + + /// Channel related typing users stream. + Stream> get typingEventsStream => _typingEventsController.stream; + + /// Channel related typing users last value. + Map get typingEvents => _typingEventsController.value; + final _typingEventsController = BehaviorSubject.seeded({}); + + void _listenTypingEvents() { + _subscriptions + ..add( + _channel.on(EventType.typingStart).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) return; + + final events = {...typingEvents, user: event}; + _typingEventsController.safeAdd(events); + }, + ), + ) + ..add( + _channel.on(EventType.typingStop).listen( + (event) { + final user = event.user; + if (user == null) return; + + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) return; + + final events = {...typingEvents}..remove(user); + _typingEventsController.safeAdd(events); + }, + ), + ); + } + + Timer? _staleTypingEventsCleanerTimer; + + // Checks and removes stale typing events that were not explicitly stopped by + // the sender due to technical difficulties. e.g. process death, loss of + // Internet connection or custom implementation. + void _startCleaningStaleTypingEvents() { + _staleTypingEventsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final now = DateTime.now(); + typingEvents.forEach((user, event) { + if (now.difference(event.createdAt).inSeconds > incomingTypingStartEventTimeout) { + _client.handleEvent( + Event( + type: EventType.typingStop, + user: user, + cid: _channel.cid, + parentId: event.parentId, + ), + ); + } + }); + }, + ); + } + + Timer? _stalePinnedMessagesCleanerTimer; + + // Checks and removes stale pinned messages that are not valid anymore. + void _startCleaningStalePinnedMessages() { + _stalePinnedMessagesCleanerTimer = Timer.periodic( + const Duration(seconds: 30), + (_) { + final now = DateTime.now(); + var expiredMessages = channelState.pinnedMessages?.where((m) => m.pinExpires?.isBefore(now) == true).toList(); + if (expiredMessages != null && expiredMessages.isNotEmpty) { + expiredMessages = expiredMessages + .map( + (m) => m.copyWith( + pinExpires: null, + pinned: false, + ), + ) + .toList(); + + updateChannelState( + _channelState.copyWith( + pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), + messages: expiredMessages, + ), + ); + } + }, + ); + } + + Timer? _staleLiveLocationsCleanerTimer; + void _startCleaningExpiredLocations() { + _staleLiveLocationsCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final currentUserId = _channel.client.state.currentUser?.id; + if (currentUserId == null) return; + + final expired = activeLiveLocations.where((it) => it.isExpired); + if (expired.isEmpty) return; + + for (final sharedLocation in expired) { + // Skip if the location is shared by the current user, + // as we are already handling them in the client. + if (sharedLocation.userId == currentUserId) continue; + + final lastUpdatedAt = DateTime.timestamp(); + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: sharedLocation.channelCid, + message: Message( + id: sharedLocation.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: sharedLocation.copyWith( + updatedAt: lastUpdatedAt, + ), + ), + ); + + _channel.client.handleEvent(locationExpiredEvent); + } + }, + ); + } + + // Listens to channel push preference update events and updates the state + void _listenChannelPushPreferenceUpdated() { + _subscriptions.add( + _channel.on(EventType.channelPushPreferenceUpdated).listen( + (event) { + final pushPreferences = event.channelPushPreference; + if (pushPreferences == null) return; + + updateChannelState( + channelState.copyWith( + pushPreferences: pushPreferences, + ), + ); + }, + ), + ); + } + + Future _deleteMessagesFromUser({ + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) async { + // Delete messages from persistence. + // + // Note: We perform this operation separately even though [_removeMessages] + // already handles it as we need to delete all messages from the user, not + // only the ones present in the current state. + final persistence = _channel.client.chatPersistenceClient; + await persistence?.deleteMessagesFromUser( + userId: userId, + cid: _channel.cid, + hardDelete: hardDelete, + deletedAt: deletedAt, + ); + + // Gather messages to delete from state. + final userMessages = {}; + for (final message in [...messages, ...threads.values.flattened]) { + if (message.user?.id != userId) continue; + userMessages[message.id] = message.copyWith( + type: MessageType.deleted, + deletedAt: deletedAt ?? DateTime.now(), + state: switch (hardDelete) { + true => MessageState.hardDeleted, + false => MessageState.softDeleted, + }, + ); + } + + final messagesToDelete = userMessages.values; + return _deleteMessages(messagesToDelete, hardDelete: hardDelete); + } + + void _deleteMessages( + Iterable messages, { + bool hardDelete = false, + }) { + if (messages.isEmpty) return; + + if (hardDelete) return _removeMessages(messages); + return _updateMessages(messages, upsert: false); + } + + void _updateMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (messages.isEmpty) return; + + _updateThreadMessages(messages, update: update, upsert: upsert); + _updateChannelMessages(messages, update: update, upsert: upsert); + _updatePinnedMessages(messages, update: update); + _updateActiveLiveLocations(messages); + } + + void _updateThreadMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (messages.isEmpty) return; + + // Group messages by parentId so each thread merge only sees its own + // replies — passing the full batch to every thread would leak replies + // across thread boundaries (the merge dedups by id, not by parentId). + final messagesByThread = >{}; + for (final m in messages) { + if (m.parentId case final parentId?) (messagesByThread[parentId] ??= []).add(m); + } + + // If there are no affected threads, return early. + if (messagesByThread.isEmpty) return; + + final updatedThreads = {...threads}; + for (final MapEntry(key: thread, :value) in messagesByThread.entries) { + final existingThreadMessages = updatedThreads[thread]; + + // Don't create a phantom entry for a thread that wasn't loaded: with + // `upsert: false` an out-of-window reply is dropped, so there's nothing + // to merge. Writing it back would make `threads.containsKey(parentId)` + // report a thread that was never paged in. + if (existingThreadMessages == null && !upsert) continue; + + final threadMessages = existingThreadMessages ?? []; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: value, + update: update, + upsert: upsert, + ); + + // Update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages.toList(); + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _updateChannelMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (messages.isEmpty) return; + + final affectedMessages = messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _mergeMessagesIntoExisting( + existing: channelMessages, + toMerge: affectedMessages, + update: update, + upsert: upsert, + ); + + // Calculate the new last message at time. + var lastMessageAt = _channelState.channel?.lastMessageAt; + for (final message in affectedMessages) { + if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) { + lastMessageAt = [lastMessageAt, message.createdAt].nonNulls.max; + } + } + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages.toList(), + channel: _channelState.channel?.copyWith(lastMessageAt: lastMessageAt), + ); + } + + void _updatePinnedMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + if (messages.isEmpty) return; + + // No-op fast path: nothing was pinned, and nothing in the batch is + // becoming pinned — skip the merge/copyWith churn that would otherwise + // land right back on an empty `pinnedMessages` list. + if (pinnedMessages.isEmpty && messages.every((m) => !m.pinned)) return; + + final updatedPinnedMessages = _mergePinnedMessagesIntoExisting( + existing: pinnedMessages, + toMerge: messages, + update: update, + ); + + _channelState = _channelState.copyWith( + pinnedMessages: updatedPinnedMessages.toList(), + ); + } + + void _updateActiveLiveLocations(Iterable messages) { + if (messages.isEmpty) return; + + final activeLiveLocations = [...this.activeLiveLocations]; + final updatedActiveLiveLocations = _mergeActiveLocationsIntoExisting( + existing: activeLiveLocations, + toMerge: messages, + ); + + _channelState = _channelState.copyWith( + activeLiveLocations: updatedActiveLiveLocations.toList(), + ); + } + + Iterable _mergeActiveLocationsIntoExisting({ + required Iterable existing, + required Iterable toMerge, + }) { + if (toMerge.isEmpty) return existing; + + final mergedLocations = existing.mergeFrom( + toMerge, + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + value: (message) => message.sharedLocation, + update: (original, updated) => updated, + ); + + final toUpdateMap = {for (final m in toMerge) m.id: m}; + final updatedLocations = mergedLocations.where((it) { + // Remove the location if it's expired. + if (it.isExpired) return false; + + final updatedMessage = toUpdateMap[it.messageId]; + // Remove the location if the attached message is deleted. + if (updatedMessage?.isDeleted == true) return false; + + return true; + }); + + return updatedLocations; + } + + Iterable _mergePinnedMessagesIntoExisting({ + required Iterable existing, + required Iterable toMerge, + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + return _mergeMessagesIntoExisting( + existing: existing, + toMerge: toMerge, + update: update, + ).where(_pinIsValid); + } + + Iterable _mergeMessagesIntoExisting({ + required Iterable existing, + required Iterable toMerge, + Message Function(Message original, Message updated) update = _mergeUpdate, + bool upsert = true, + }) { + if (toMerge.isEmpty) return existing; + + // [update] decides whether each pair is reconciled (default — see + // `_mergeUpdate`) or replaced (`_replaceUpdate`, used by local rollback + // paths that don't want enrichment fallback to keep optimistic values). + // + // [upsert] controls whether ids not already in [existing] are inserted. + // Event-driven paths (`message.updated`, `message.deleted` soft) pass + // `upsert: false` so an out-of-window message isn't dropped into a gap + // between the loaded slice and history the client hasn't paged in yet. + final existingList = existing is List ? existing : existing.toList(); + var toMergeList = toMerge is List ? toMerge : toMerge.toList(); + + // Single-message fast path. The hot ingest path (server echoes, edits, + // reactions, read receipts) always lands here, and `lastIndexWhere` + + // `sortedUpsertAt` skips the O(N) keymap build that the two-pointer + // merge would otherwise do up front. + if (toMergeList.length == 1) { + final message = toMergeList.first; + final oldIndex = existingList.lastIndexWhere((it) => it.id == message.id); + + // upsert: false — skip update if message is not loaded + if (oldIndex == -1 && !upsert) return existingList; + + final resolved = oldIndex == -1 ? message : update(existingList[oldIndex], message); + + final mergedMessages = existingList.sortedUpsertAt( + oldIndex, + resolved, + update: update, + compare: _sortByCreatedAt, + ); + + // Non-delete updates can't change what embedded quotedMessage copies + // should display, so we can skip the rewrite entirely. + if (!resolved.isDeleted) return mergedMessages; + + return mergedMessages.updateIf( + (it) => it.quotedMessageId == resolved.id, + (it) => it.copyWith(quotedMessage: resolved), + ); + } + + // upsert: false - skip messages not loaded in the window + if (!upsert) { + final existingIds = {for (final m in existingList) m.id}; + toMergeList = toMergeList.where((m) => existingIds.contains(m.id)).toList(); + if (toMergeList.isEmpty) return existingList; + } + + // Batch path: receiver (`existingList`) is maintained sorted as a + // state invariant; `mergeSorted` sorts `toMergeList` internally and + // returns a sorted result. + final mergedMessages = existingList.mergeSorted( + toMergeList, + key: (message) => message.id, + update: update, + compare: _sortByCreatedAt, + ); + + // Refresh embedded `quotedMessage` refs only for messages quoting an + // incoming message that is now deleted. `updateIf` returns the same + // list reference when nothing matches, so steady-state allocates + // nothing for this step. + final deletedIds = toMergeList.where((m) => m.isDeleted).map((m) => m.id).toSet(); + if (deletedIds.isEmpty) return mergedMessages; + + final mergedById = {for (final m in mergedMessages) m.id: m}; + return mergedMessages.updateIf( + (it) => deletedIds.contains(it.quotedMessageId), + (it) => it.copyWith(quotedMessage: mergedById[it.quotedMessageId]), + ); + } + + void _removeMessages(Iterable messages) { + if (messages.isEmpty) return; + + final messageIds = messages.map((m) => m.id).toSet().toList(); + final persistenceClient = _channel.client.chatPersistenceClient; + // Remove the messages from the persistence client. + persistenceClient?.deleteMessageByIds(messageIds); + persistenceClient?.deletePinnedMessageByIds(messageIds); + + _removeThreadMessages(messages); + _removeChannelMessages(messages); + _removePinnedMessages(messages); + _removeActiveLiveLocations(messages); + } + + void _removeThreadMessages(Iterable messages) { + if (messages.isEmpty) return; + + final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; + // If there are no affected threads, return early. + if (affectedThreads.isEmpty) return; + + final updatedThreads = {...threads}; + for (final thread in affectedThreads) { + final threadMessages = updatedThreads[thread]; + // Continue if the thread doesn't exist. + if (threadMessages == null) continue; + + // Remove the deleted message from the thread messages and reference from + // other messages quoting it. + final updatedThreadMessages = _removeMessagesFromExisting( + existing: threadMessages, + toRemove: messages, + ); + + // If there are no more messages in the thread, remove the thread entry. + if (updatedThreadMessages.isEmpty) { + updatedThreads.remove(thread); + continue; + } + + // Otherwise, update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages.toList(); + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _removeChannelMessages(Iterable messages) { + if (messages.isEmpty) return; + + final affectedMessages = messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _removeMessagesFromExisting( + existing: channelMessages, + toRemove: affectedMessages, + ); + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages.toList(), + ); + } + + void _removePinnedMessages(Iterable messages) { + if (messages.isEmpty) return; + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _removePinnedMessagesFromExisting( + existing: pinnedMessages, + toRemove: messages, + ); + + _channelState = _channelState.copyWith( + pinnedMessages: updatedPinnedMessages.toList(), + ); + } + + void _removeActiveLiveLocations(Iterable messages) { + if (messages.isEmpty) return; + + final activeLiveLocations = [...this.activeLiveLocations]; + final updatedActiveLiveLocations = _removeActiveLocationsFromExisting( + existing: activeLiveLocations, + toRemove: messages, + ); + + _channelState = _channelState.copyWith( + activeLiveLocations: updatedActiveLiveLocations.toList(), + ); + } + + Iterable _removeActiveLocationsFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + if (toRemove.isEmpty) return existing; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedLocations = existing.where( + // Remove the location if its attached message is in the toRemove list. + (it) => !toRemoveIds.contains(it.messageId), + ); + + return updatedLocations; + } + + Iterable _removePinnedMessagesFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + return _removeMessagesFromExisting( + existing: existing, + toRemove: toRemove, + ).where(_pinIsValid); + } + + Iterable _removeMessagesFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + if (toRemove.isEmpty) return existing; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedMessages = existing + .where((it) { + // Remove the message if it's in the toRemove list. + return !toRemoveIds.contains(it.id); + }) + .map((it) { + // Continue if the message doesn't quote any of the deleted messages. + if (!toRemoveIds.contains(it.quotedMessageId)) return it; + + // Setting it to null will remove the quoted message from the message. + return it.copyWith(quotedMessageId: null, quotedMessage: null); + }); + + return updatedMessages; + } + + // Listens to user message deleted events and marks messages from that user + // as either soft or hard deleted based on the event data. + void _listenUserMessagesDeleted() { + _subscriptions.add( + _channel.on(EventType.userMessagesDeleted).listen((event) async { + final user = event.user; + if (user == null) return; + + return _deleteMessagesFromUser( + userId: user.id, + hardDelete: event.hardDelete ?? false, + deletedAt: event.createdAt, + ); + }), + ); + } + + /// Call this method to dispose this object. + void dispose() { + _debouncedUpdatePersistenceChannelThreads.cancel(); + _debouncedUpdatePersistenceChannelState.cancel(); + _retryQueue.dispose(); + _subscriptions.cancel(); + _channelStateController.close(); + _isUpToDateController.close(); + _threadsController.close(); + _staleTypingEventsCleanerTimer?.cancel(); + _stalePinnedMessagesCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer?.cancel(); + _typingEventsController.close(); + } +} + +bool _pinIsValid(Message message) { + // If the message is deleted, the pin is not valid. + if (message.isDeleted) return false; + + // If the message is not pinned, it's not valid. + if (message.pinned != true) return false; + + // If there's no expiration, the pin is valid. + final pinExpires = message.pinExpires; + if (pinExpires == null) return true; + + // If there's an expiration, check if it's still valid. + return pinExpires.isAfter(DateTime.now()); +} diff --git a/packages/stream_chat/lib/src/client/channel/channel_read_helper.dart b/packages/stream_chat/lib/src/client/channel/channel_read_helper.dart new file mode 100644 index 0000000000..558404d3a3 --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel/channel_read_helper.dart @@ -0,0 +1,52 @@ +import '../../../stream_chat.dart'; + +/// Extension methods for reading related operations on a ChannelClientState. +extension ChannelReadHelper on ChannelClientState { + /// Get the [Read] object for a specific user identified by [userId]. + Read? userReadOf({String? userId}) => read.userReadOf(userId: userId); + + /// Stream of [Read] object for a specific user identified by [userId]. + Stream userReadStreamOf({String? userId}) { + return readStream.map((read) => read.userReadOf(userId: userId)); + } + + /// Returns the list of [Read]s that have marked the given [message] as read. + /// + /// The [Read] is considered to have read the message if: + /// - The read user is not the sender of the message. + /// - The read's lastRead is after or equal to the message's createdAt. + List readsOf({required Message message}) { + return read.readsOf(message: message); + } + + /// Stream of list of [Read]s that have marked the given [message] as read. + /// + /// The [Read] is considered to have read the message if: + /// - The read user is not the sender of the message. + /// - The read's lastRead is after or equal to the message's createdAt. + Stream> readsOfStream({required Message message}) { + return readStream.map((read) => read.readsOf(message: message)); + } + + /// Returns the list of [Read]s that have marked the given [message] as + /// delivered. + /// + /// The [Read] is considered to have delivered the message if: + /// - The read user is not the sender of the message. + /// - The read contains a non-null lastDeliveredAt. + /// - The read's lastDeliveredAt is after or equal to the message's createdAt. + List deliveriesOf({required Message message}) { + return read.deliveriesOf(message: message); + } + + /// Stream of list of [Read]s that have marked the given [message] as + /// delivered. + /// + /// The [Read] is considered to have delivered the message if: + /// - The read user is not the sender of the message. + /// - The read contains a non-null lastDeliveredAt. + /// - The read's lastDeliveredAt is after or equal to the message's createdAt. + Stream> deliveriesOfStream({required Message message}) { + return readStream.map((read) => read.deliveriesOf(message: message)); + } +} diff --git a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart index e3c7d7a4e1..3c9217d9c6 100644 --- a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart +++ b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart @@ -5,7 +5,7 @@ import 'package:synchronized/synchronized.dart'; import '../core/models/message.dart'; import '../core/models/message_delivery.dart'; import '../core/util/message_rules.dart'; -import 'channel.dart'; +import 'channel/channel.dart'; /// A callback that sends delivery receipts for multiple channels. /// diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 123685cbb4..4bc98f992e 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -54,7 +54,7 @@ import '../event_type.dart'; import '../system_environment.dart'; import '../ws/connection_status.dart'; import '../ws/websocket.dart'; -import 'channel.dart'; +import 'channel/channel.dart'; import 'channel_delivery_reporter.dart'; import 'event_resolvers.dart' as event_resolvers; import 'query_channels_result.dart'; diff --git a/packages/stream_chat/lib/src/client/query_channels_result.dart b/packages/stream_chat/lib/src/client/query_channels_result.dart index 4ca3abd47f..c28e3740e0 100644 --- a/packages/stream_chat/lib/src/client/query_channels_result.dart +++ b/packages/stream_chat/lib/src/client/query_channels_result.dart @@ -1,5 +1,5 @@ import '../core/models/predefined_filter.dart'; -import 'channel.dart'; +import 'channel/channel.dart'; /// The result of a `queryChannelsWithResult` call on [StreamChatClient]. /// diff --git a/packages/stream_chat/lib/src/core/util/message_rules.dart b/packages/stream_chat/lib/src/core/util/message_rules.dart index 52c89b4517..b368f3ec7d 100644 --- a/packages/stream_chat/lib/src/core/util/message_rules.dart +++ b/packages/stream_chat/lib/src/core/util/message_rules.dart @@ -1,4 +1,5 @@ -import '../../client/channel.dart'; +import '../../client/channel/channel.dart'; +import '../../client/channel/channel_capability_check.dart'; import '../models/message.dart'; import '../models/own_user.dart'; diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index d81495aeef..9f4a057352 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -16,7 +16,10 @@ export 'package:logging/logging.dart' show Logger, Level, LogRecord; export 'package:rate_limiter/rate_limiter.dart'; export 'package:uuid/uuid.dart'; -export 'src/client/channel.dart'; +export 'src/client/channel/channel.dart'; +export 'src/client/channel/channel_capability_check.dart'; +export 'src/client/channel/channel_client_state.dart'; +export 'src/client/channel/channel_read_helper.dart'; export 'src/client/channel_delivery_reporter.dart'; export 'src/client/client.dart'; export 'src/client/key_stroke_handler.dart'; diff --git a/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart b/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart new file mode 100644 index 0000000000..cf02992e4e --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_capability_check_test.dart @@ -0,0 +1,410 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../mocks.dart'; + +void main() { + group('ChannelCapabilityCheck', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); + + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + /// Parameterized test for channel capability extension properties + void testCapability( + String capabilityName, + ChannelCapability capability, + bool Function(Channel) getterMethod, + ) { + test('can$capabilityName returns false when capability is absent', () { + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + expect(getterMethod(channel), false); + }); + + test('can$capabilityName returns true when capability is present', () { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [capability], + ); + final channel = Channel.fromState(client, channelState); + expect(getterMethod(channel), true); + }); + } + + // Test all channel capabilities using the parameterized function + testCapability( + 'SendMessage', + ChannelCapability.sendMessage, + (channel) => channel.canSendMessage, + ); + + testCapability( + 'SendReply', + ChannelCapability.sendReply, + (channel) => channel.canSendReply, + ); + + testCapability( + 'SendRestrictedVisibilityMessage', + ChannelCapability.sendRestrictedVisibilityMessage, + (channel) => channel.canSendRestrictedVisibilityMessage, + ); + + testCapability( + 'SendReaction', + ChannelCapability.sendReaction, + (channel) => channel.canSendReaction, + ); + + testCapability( + 'SendLinks', + ChannelCapability.sendLinks, + (channel) => channel.canSendLinks, + ); + + testCapability( + 'CreateAttachment', + ChannelCapability.createAttachment, + (channel) => channel.canCreateAttachment, + ); + + testCapability( + 'FreezeChannel', + ChannelCapability.freezeChannel, + (channel) => channel.canFreezeChannel, + ); + + testCapability( + 'SetChannelCooldown', + ChannelCapability.setChannelCooldown, + (channel) => channel.canSetChannelCooldown, + ); + + testCapability( + 'LeaveChannel', + ChannelCapability.leaveChannel, + (channel) => channel.canLeaveChannel, + ); + + testCapability( + 'JoinChannel', + ChannelCapability.joinChannel, + (channel) => channel.canJoinChannel, + ); + + testCapability( + 'PinMessage', + ChannelCapability.pinMessage, + (channel) => channel.canPinMessage, + ); + + testCapability( + 'DeleteAnyMessage', + ChannelCapability.deleteAnyMessage, + (channel) => channel.canDeleteAnyMessage, + ); + + testCapability( + 'DeleteOwnMessage', + ChannelCapability.deleteOwnMessage, + (channel) => channel.canDeleteOwnMessage, + ); + + testCapability( + 'UpdateAnyMessage', + ChannelCapability.updateAnyMessage, + (channel) => channel.canUpdateAnyMessage, + ); + + testCapability( + 'UpdateOwnMessage', + ChannelCapability.updateOwnMessage, + (channel) => channel.canUpdateOwnMessage, + ); + + testCapability( + 'SearchMessages', + ChannelCapability.searchMessages, + (channel) => channel.canSearchMessages, + ); + + testCapability( + 'SendTypingEvents', + ChannelCapability.sendTypingEvents, + (channel) => channel.canSendTypingEvents, + ); + + testCapability( + 'UploadFile', + ChannelCapability.uploadFile, + (channel) => channel.canUploadFile, + ); + + testCapability( + 'DeleteChannel', + ChannelCapability.deleteChannel, + (channel) => channel.canDeleteChannel, + ); + + testCapability( + 'UpdateChannel', + ChannelCapability.updateChannel, + (channel) => channel.canUpdateChannel, + ); + + testCapability( + 'UpdateChannelMembers', + ChannelCapability.updateChannelMembers, + (channel) => channel.canUpdateChannelMembers, + ); + + testCapability( + 'UpdateThread', + ChannelCapability.updateThread, + (channel) => channel.canUpdateThread, + ); + + testCapability( + 'QuoteMessage', + ChannelCapability.quoteMessage, + (channel) => channel.canQuoteMessage, + ); + + testCapability( + 'BanChannelMembers', + ChannelCapability.banChannelMembers, + (channel) => channel.canBanChannelMembers, + ); + + testCapability( + 'FlagMessage', + ChannelCapability.flagMessage, + (channel) => channel.canFlagMessage, + ); + + testCapability( + 'MuteChannel', + ChannelCapability.muteChannel, + (channel) => channel.canMuteChannel, + ); + + testCapability( + 'SendCustomEvents', + ChannelCapability.sendCustomEvents, + (channel) => channel.canSendCustomEvents, + ); + + testCapability( + 'ReceiveReadEvents', + ChannelCapability.readEvents, + (channel) => channel.canReceiveReadEvents, + ); + + testCapability( + 'UseReadReceipts', + ChannelCapability.readEvents, + (channel) => channel.canUseReadReceipts, + ); + + testCapability( + 'ReceiveConnectEvents', + ChannelCapability.connectEvents, + (channel) => channel.canReceiveConnectEvents, + ); + + testCapability( + 'UseTypingEvents', + ChannelCapability.typingEvents, + (channel) => channel.canUseTypingEvents, + ); + + testCapability( + 'InSlowMode', + ChannelCapability.slowMode, + (channel) => channel.isInSlowMode, + ); + + testCapability( + 'SkipSlowMode', + ChannelCapability.skipSlowMode, + (channel) => channel.canSkipSlowMode, + ); + + testCapability( + 'SendPoll', + ChannelCapability.sendPoll, + (channel) => channel.canSendPoll, + ); + + testCapability( + 'CastPollVote', + ChannelCapability.castPollVote, + (channel) => channel.canCastPollVote, + ); + + testCapability( + 'QueryPollVotes', + ChannelCapability.queryPollVotes, + (channel) => channel.canQueryPollVotes, + ); + + testCapability( + 'UseDeliveryReceipts', + ChannelCapability.deliveryEvents, + (channel) => channel.canUseDeliveryReceipts, + ); + + testCapability( + 'ShareLocation', + ChannelCapability.shareLocation, + (channel) => channel.canShareLocation, + ); + + testCapability( + 'NotifyChannel', + ChannelCapability.notifyChannel, + (channel) => channel.canNotifyChannel, + ); + + testCapability( + 'NotifyHere', + ChannelCapability.notifyHere, + (channel) => channel.canNotifyHere, + ); + + testCapability( + 'NotifyRole', + ChannelCapability.notifyRole, + (channel) => channel.canNotifyRole, + ); + + testCapability( + 'NotifyGroup', + ChannelCapability.notifyGroup, + (channel) => channel.canNotifyGroup, + ); + + test('returns correct values with multiple capabilities', () { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.deleteOwnMessage, + ], + ); + + final channel = Channel.fromState(client, channelState); + expect(channel.canSendMessage, true); + expect(channel.canSendReply, true); + expect(channel.canDeleteOwnMessage, true); + expect(channel.canDeleteAnyMessage, false); + expect(channel.canUpdateChannel, false); + }); + + group('usesLocalUnreadCount', () { + // `isLocalUnreadCountEnabled` is a settable field on the mock and the + // client is shared across the group, so reset it between tests. + tearDown(() => client.isLocalUnreadCountEnabled = false); + + Channel channelWithReadEvents({required bool available}) { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ + if (available) ChannelCapability.readEvents, + ], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + return channel; + } + + test('is false when disabled and read receipts are unavailable', () { + client.isLocalUnreadCountEnabled = false; + final channel = channelWithReadEvents(available: false); + expect(channel.usesLocalUnreadCount, false); + }); + + test('is false when disabled and read receipts are available', () { + client.isLocalUnreadCountEnabled = false; + final channel = channelWithReadEvents(available: true); + expect(channel.usesLocalUnreadCount, false); + }); + + test('is false when enabled but the channel supports read receipts', () { + client.isLocalUnreadCountEnabled = true; + final channel = channelWithReadEvents(available: true); + expect(channel.usesLocalUnreadCount, false); + }); + + test('is true when enabled and read receipts are unavailable', () { + client.isLocalUnreadCountEnabled = true; + final channel = channelWithReadEvents(available: false); + expect(channel.usesLocalUnreadCount, true); + }); + }); + }); +} + +// region Test Helpers + +ChannelState _generateChannelState( + String channelId, + String channelType, { + DateTime? lastMessageAt, + List? ownCapabilities, + bool mockChannelConfig = false, +}) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ownCapabilities: ownCapabilities, + lastMessageAt: lastMessageAt, + ); + return ChannelState(channel: channel); +} + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +// endregion diff --git a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart new file mode 100644 index 0000000000..c7b5114328 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart @@ -0,0 +1,5124 @@ +// ignore_for_file: lines_longer_than_80_chars, cascade_invocations, deprecated_member_use_from_same_package, avoid_redundant_argument_values + +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../mocks.dart'; + +void main() { + group('WS events', () { + late final client = MockStreamChatClient(); + + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); + + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + // mock channel delivery reporter + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + }); + + group( + '${EventType.messageNew} or ${EventType.notificationMessageNew}', + () { + final initialLastMessageAt = DateTime.now(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + lastMessageAt: initialLastMessageAt, + ); + + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + Event createNewMessageEvent(Message message) { + return Event( + cid: channel.cid, + type: EventType.messageNew, + message: message, + ); + } + + test( + "should update 'channel.lastMessageAt'", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, equals(message.createdAt)); + expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + }, + ); + + test( + "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Message is visible to the current user. + restrictedVisibility: [client.state.currentUser!.id], + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, equals(message.createdAt)); + expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Older than the current 'channel.lastMessageAt'. + createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message is shadowed", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + shadowed: true, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message is ephemeral", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + type: MessageType.ephemeral, + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Message is only visible to user-1 not the current user. + restrictedVisibility: const ['user-1'], + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test( + "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + + when( + () => channel.config?.skipLastMsgUpdateForSystemMsgs, + ).thenReturn(true); + + final message = Message( + type: MessageType.system, + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); + + test("should update 'unreadCount'", () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(1)); + + final message2 = Message( + id: 'test-message-id-2', + user: User(id: 'other-user'), + createdAt: message.createdAt.add(const Duration(seconds: 3)), + ); + + final newMessage2Event = createNewMessageEvent(message2); + client.addEvent(newMessage2Event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(2)); + }); + + group("should not update 'unreadCount'", () { + test( + 'when the message is silent', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + silent: true, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is shadowed', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + shadowed: true, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message type is ephemeral', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + type: MessageType.ephemeral, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is a thread reply', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is a thread reply', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is from the current user', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test( + 'when the message is not restricted for the current user', + () async { + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + restrictedVisibility: const ['other-user-2'], + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + }); + + test( + 'should submit channel for delivery when message is received', + () async { + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); + + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Verify submitForDelivery was called + verify( + () => client.channelDeliveryReporter.submitForDelivery([channel]), + ).called(1); + }, + ); + + test( + 'should not duplicate when server echoes back an optimistically ' + 'inserted message with a later createdAt', + () async { + // Local message used as the input to `channel.sendMessage`. + final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 3)); + final localMessage = Message( + id: 'test-message-id', + text: 'Hello world!', + user: client.state.currentUser, + createdAt: localCreatedAt, + ); + + // Mock the network send to return the message unchanged so the + // optimistic insert + sent-state update both land on the same + // `createdAt`. The bug fires later, on the WS echo. + final sendMessageResponse = SendMessageResponse() + ..message = localMessage.copyWith(state: MessageState.sent); + when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + + await channel.sendMessage(localMessage); + + expect(channel.state!.messages, hasLength(1)); + + // Server then broadcasts the same message via a `message.new` + // event with a slightly later `createdAt` (server-assigned + // timestamp). + final serverMessage = localMessage.copyWith( + createdAt: localCreatedAt.add(const Duration(milliseconds: 50)), + ); + client.addEvent(createNewMessageEvent(serverMessage)); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // The state should contain exactly one message with that id, + // not a duplicate. + final matching = channel.state!.messages.where((it) => it.id == localMessage.id); + expect(matching, hasLength(1)); + expect(channel.state!.messages, hasLength(1)); + }, + ); + + test( + 'should not duplicate when the locally-sent message is no longer ' + 'the latest (retry-after-offline scenario)', + () async { + // Mirrors the offline-retry flow: a local message is sent, then + // another message arrives via WS while the local one is still + // pending. When the retry finally succeeds the server response's + // `createdAt` is later than the intervening message, so the + // locally-sent copy is no longer `messages.last`. + final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 1)); + final localMessage = Message( + id: 'local-message-id', + text: 'Hello world!', + user: client.state.currentUser, + createdAt: localCreatedAt, + ); + + final sendMessageResponse = SendMessageResponse() + ..message = localMessage.copyWith(state: MessageState.sent); + when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + + await channel.sendMessage(localMessage); + + // Another message arrives via WS with a later `createdAt`, + // pushing the locally-sent message off the tail. + final otherMessage = Message( + id: 'other-message-id', + user: User(id: 'other-user'), + createdAt: localCreatedAt.add(const Duration(seconds: 2)), + ); + client.addEvent(createNewMessageEvent(otherMessage)); + await Future.delayed(Duration.zero); + + // Server then broadcasts the locally-sent message via + // `message.new` with a `createdAt` that is later than the + // intervening message — exactly the shape produced by a + // successful retry after another message arrived in between. + final serverEcho = localMessage.copyWith( + createdAt: otherMessage.createdAt.add(const Duration(seconds: 1)), + ); + client.addEvent(createNewMessageEvent(serverEcho)); + await Future.delayed(Duration.zero); + + final localMatches = channel.state!.messages.where((it) => it.id == localMessage.id); + expect(localMatches, hasLength(1)); + expect(channel.state!.messages, hasLength(2)); + }, + ); + }, + ); + + group( + EventType.messageUpdated, + () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + Event createUpdateMessageEvent(Message message) { + return Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: message, + ); + } + + test( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); + + final newMessageEvent = createUpdateMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + }, + ); + + test( + 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', + () async { + const messageId = 'test-message-id'; + const oldText = 'Old text'; + const newText = 'New text'; + final message = Message( + id: messageId, + user: client.state.currentUser, + text: oldText, + pinned: true, + ); + + final firstUpdateEvent = createUpdateMessageEvent(message); + client.addEvent(firstUpdateEvent); + + // Wait for the first event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + expect(channel.state?.pinnedMessages.first.text, equals(oldText)); + + final updatedMessage = message.copyWith(text: newText); + final secondUpdateEvent = createUpdateMessageEvent(updatedMessage); + client.addEvent(secondUpdateEvent); + + // Wait for the second event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + expect(channel.state?.pinnedMessages.first.text, equals(newText)); + }, + ); + + test( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages " + 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', + () async { + const firstMessageId = 'first-test-message-id'; + const secondMessageId = 'second-test-message-id'; + final firstMessage = Message( + id: firstMessageId, + user: client.state.currentUser, + pinned: true, + ); + final secondMessage = firstMessage.copyWith(id: secondMessageId); + + final firstUpdateEvent = createUpdateMessageEvent(firstMessage); + client.addEvent(firstUpdateEvent); + + // Wait for the first event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect( + channel.state?.pinnedMessages.first.id, + equals(firstMessageId), + ); + + final secondUpdateEvent = createUpdateMessageEvent(secondMessage); + client.addEvent(secondUpdateEvent); + + // Wait for the second event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(2)); + expect( + channel.state?.pinnedMessages.first.id, + equals(firstMessageId), + ); + expect( + channel.state?.pinnedMessages[1].id, + equals(secondMessageId), + ); + }, + ); + + test( + "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", + () async { + const messageId = 'test-message-id'; + final pinnedMessage = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); + + final pinEvent = createUpdateMessageEvent(pinnedMessage); + client.addEvent(pinEvent); + + // Wait for the pin event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + + final unpinnedMessage = pinnedMessage.copyWith(pinned: false); + final unpinEvent = createUpdateMessageEvent(unpinnedMessage); + client.addEvent(unpinEvent); + + // Wait for the unpin event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages, isEmpty); + }, + ); + + // A `message.updated` event for a message outside the loaded window + // would otherwise upsert into the sorted list — creating a phantom + // entry with a gap. The guard is "id not in the loaded list", and + // is independent of `isUpToDate` — even at the latest page we may + // have paginated past older history and receive an event for a + // message no longer in memory. + group('when message is outside the loaded window', () { + test( + 'should NOT insert unknown message into `messages` list', + () async { + // Simulate "we have the latest page but not older history": + // seed the tail messages. + final tail = List.generate( + 3, + (i) => Message( + id: 'tail-$i', + user: client.state.currentUser, + text: 'tail $i', + createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), + ), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: tail), + ); + expect(channel.state!.messages, hasLength(3)); + + // Event for a message on an older page we don't have loaded. + final olderPageEdit = Message( + id: 'older-page-msg', + user: client.state.currentUser, + text: 'edited on older page', + createdAt: DateTime.utc(2025, 1, 1), + ); + client.addEvent(createUpdateMessageEvent(olderPageEdit)); + await Future.delayed(Duration.zero); + + // Tail is unchanged, no phantom entry inserted at position 0. + expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + expect(channel.state!.pinnedMessages, isEmpty); + }, + ); + + test( + 'should update message in place when it IS in the loaded window', + () async { + const messageId = 'known'; + final seeded = Message( + id: messageId, + user: client.state.currentUser, + text: 'old', + createdAt: DateTime.utc(2026), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: [seeded]), + ); + channel.state!.isUpToDate = false; + + final edited = seeded.copyWith(text: 'new'); + client.addEvent(createUpdateMessageEvent(edited)); + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); + expect(stored.text, equals('new')); + }, + ); + + test( + 'should still add to pinnedMessages when pinned:true even if not in loaded window', + () async { + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, isEmpty); + + const messageId = 'pin-me'; + final pinned = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); + client.addEvent(createUpdateMessageEvent(pinned)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages.length, equals(1)); + expect(channel.state!.pinnedMessages.first.id, equals(messageId)); + }, + ); + + test( + 'should NOT insert unknown reply into threads[parentId]', + () async { + const parentId = 'parent-1'; + final knownReply = Message( + id: 'known-reply', + parentId: parentId, + user: client.state.currentUser, + createdAt: DateTime.utc(2026), + ); + // Populate threads[parentId] via addNewMessage's thread-only path. + channel.state!.addNewMessage(knownReply); + await Future.delayed(Duration.zero); + expect(channel.state!.threads[parentId], hasLength(1)); + + channel.state!.isUpToDate = false; + + final phantomReply = Message( + id: 'other-reply', + parentId: parentId, + user: client.state.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + client.addEvent(createUpdateMessageEvent(phantomReply)); + await Future.delayed(Duration.zero); + + expect(channel.state!.threads[parentId]!.map((m) => m.id), ['known-reply']); + }, + ); + + test( + 'should NOT create phantom threads[parentId] entry for unloaded thread', + () async { + const parentId = 'unloaded-parent'; + // The thread was never paged in, so there's no entry for it. + expect(channel.state!.threads.containsKey(parentId), isFalse); + + channel.state!.isUpToDate = false; + + final phantomReply = Message( + id: 'phantom-reply', + parentId: parentId, + user: client.state.currentUser, + text: 'edited', + createdAt: DateTime.utc(2026, 1, 2), + ); + client.addEvent(createUpdateMessageEvent(phantomReply)); + await Future.delayed(Duration.zero); + + // The dropped reply must not leave behind an empty thread entry. + expect(channel.state!.threads.containsKey(parentId), isFalse); + }, + ); + + test( + 'should still expire activeLiveLocations for out-of-window message', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'loc-msg', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + // Seed only activeLiveLocations, keeping `messages` empty — + // the exact "message is outside the loaded window" scenario. + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + activeLiveLocations: [liveLocation], + ), + ); + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, hasLength(1)); + + // A message.updated that expires the live location. + final expiredMessage = Message( + id: 'loc-msg', + text: 'Live location shared', + sharedLocation: liveLocation.copyWith( + endAt: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + client.addEvent(createUpdateMessageEvent(expiredMessage)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, isEmpty); + }, + ); + }); + }, + ); + + // A reply with `show_in_channel = true` is mirrored into both `messages` + // and `threads[parentId]`. When the thread isn't loaded (fresh hydration, + // user never opened the thread) the channel-level copy is the only place + // locally-cached fields like `ownReactions`/`poll` survive — so reaction + // and message-update events for such replies must still find it. + group( + 'reply events with `show_in_channel = true` and unloaded thread', + () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + const replyId = 'mirrored-reply-id'; + const parentId = 'parent-message-id'; + // Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. + final createdAt = DateTime.utc(2026, 1, 1); + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + // Seeds a single reply into the channel-level `messages` while leaving + // `threads[parentId]` empty — the exact regression scenario. + Message seedMirroredReply({ + List ownReactions = const [], + Poll? poll, + }) { + final reply = Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + ownReactions: ownReactions, + poll: poll, + pollId: poll?.id, + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: [reply]), + ); + return reply; + } + + test( + '`reaction.new` from another user preserves `ownReactions`', + () async { + final ownReaction = Reaction( + type: 'like', + messageId: replyId, + user: client.state.currentUser, + ); + seedMirroredReply(ownReactions: [ownReaction]); + // Pre-condition: thread is not loaded. + expect(channel.state!.threads, isEmpty); + + // Server reaction events don't echo back the recipient's own + // reactions, so the listener must pull them from the cached copy. + final otherUserReaction = Reaction( + type: 'love', + messageId: replyId, + user: User(id: 'other-user'), + ); + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionNew, + reaction: otherUserReaction, + message: Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + latestReactions: [otherUserReaction], + ), + ), + ); + + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); + expect(stored.ownReactions, [ownReaction]); + }, + ); + + test( + '`reaction.deleted` strips only the removed reaction', + () async { + final kept = Reaction( + type: 'like', + messageId: replyId, + user: client.state.currentUser, + ); + final removed = Reaction( + type: 'love', + messageId: replyId, + user: client.state.currentUser, + ); + seedMirroredReply(ownReactions: [kept, removed]); + expect(channel.state!.threads, isEmpty); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.reactionDeleted, + reaction: removed, + message: Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + ), + ), + ); + + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); + expect(stored.ownReactions, [kept]); + }, + ); + + test( + '`message.updated` preserves `poll`, `pollId`, and `ownReactions`', + () async { + final ownReaction = Reaction( + type: 'like', + messageId: replyId, + user: client.state.currentUser, + ); + // Partial server updates can omit poll/pollId/ownReactions; the + // cached copy is what backfills them. + final poll = Poll( + id: 'poll-1', + name: 'Pick one', + options: const [ + PollOption(text: 'A'), + PollOption(text: 'B'), + ], + ); + seedMirroredReply(ownReactions: [ownReaction], poll: poll); + expect(channel.state!.threads, isEmpty); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: Message( + id: replyId, + parentId: parentId, + showInChannel: true, + user: client.state.currentUser, + createdAt: createdAt, + text: 'edited', + ), + ), + ); + + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); + expect(stored.ownReactions, [ownReaction]); + expect(stored.poll?.id, poll.id); + expect(stored.pollId, poll.id); + }, + ); + }, + ); + + // A `message.deleted` event for a message outside the loaded window + // must not upsert a "deleted" record into the sorted list — that would + // create a phantom entry with a gap. Pinned + live-location + // side-effects must still fire. + group( + EventType.messageDeleted, + () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + Event createDeleteMessageEvent(Message message, {bool hardDelete = false}) { + return Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: message.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + hardDelete: hardDelete, + ); + } + + // Same design as the `messageUpdated` guards: the check is + // "message-in-loaded-window" and is independent of `isUpToDate` — + // an event for a message on an older, unloaded page must not be + // turned into a phantom "deleted" record inserted into the sorted + // list. + group('when message is outside the loaded window', () { + test( + 'soft delete does NOT insert phantom "deleted" record into messages', + () async { + final tail = List.generate( + 3, + (i) => Message( + id: 'tail-$i', + user: client.state.currentUser, + text: 'tail $i', + createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), + ), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: tail), + ); + expect(channel.state!.messages, hasLength(3)); + + final olderPage = Message( + id: 'older-page-msg', + user: client.state.currentUser, + text: 'gone', + createdAt: DateTime.utc(2025, 1, 1), + ); + client.addEvent(createDeleteMessageEvent(olderPage)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); + }, + ); + + test( + 'soft delete marks message as deleted when it IS in the loaded window', + () async { + const messageId = 'known'; + final seeded = Message( + id: messageId, + user: client.state.currentUser, + text: 'hi', + createdAt: DateTime.utc(2026), + ); + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(messages: [seeded]), + ); + channel.state!.isUpToDate = false; + + client.addEvent(createDeleteMessageEvent(seeded)); + await Future.delayed(Duration.zero); + + final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); + expect(stored.type, equals(MessageType.deleted)); + expect(stored.deletedAt, isNotNull); + }, + ); + + test( + 'soft delete unpins a pinned-but-not-in-window message via _pinIsValid', + () async { + const messageId = 'pinned-msg'; + final pinned = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + createdAt: DateTime.utc(2026), + ); + // Seed only the pinnedMessages list — message absent from + // the main `messages` window. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(pinnedMessages: [pinned]), + ); + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, hasLength(1)); + + client.addEvent(createDeleteMessageEvent(pinned)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, isEmpty); + }, + ); + + test( + 'soft delete still clears activeLiveLocations even when message not in window', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'loc-msg', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + // Seed only activeLiveLocations, keeping `messages` empty. + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + activeLiveLocations: [liveLocation], + ), + ); + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, hasLength(1)); + + final locationMessage = Message( + id: 'loc-msg', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + client.addEvent(createDeleteMessageEvent(locationMessage)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.activeLiveLocations, isEmpty); + }, + ); + + test( + 'hard delete is a no-op when message is not in the loaded window', + () async { + channel.state!.isUpToDate = false; + expect(channel.state!.messages, isEmpty); + + final phantom = Message( + id: 'phantom', + user: client.state.currentUser, + text: 'gone', + createdAt: DateTime.utc(2026), + ); + client.addEvent(createDeleteMessageEvent(phantom, hardDelete: true)); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + expect(channel.state!.pinnedMessages, isEmpty); + }, + ); + }); + }, + ); + + group('Member Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test( + 'should update membership when member is updated and is current user', + () async { + final currentUser = client.state.currentUser; + final currentMember = Member(user: currentUser); + final now = DateTime.now(); + + // Setup initial membership + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + expect(channel.membership, isNotNull); + expect(channel.membership?.channelRole, isNull); + expect(channel.membership?.isModerator, false); + expect(channel.isPinned, isFalse); + expect(channel.isArchived, isFalse); + + // Create updated member with same userId but updated properties + final updatedMember = currentMember.copyWith( + channelRole: 'moderator', + isModerator: true, + pinnedAt: now, + archivedAt: now, + ); + + // Create member updated event + final memberUpdatedEvent = Event( + cid: channel.cid, + type: EventType.memberUpdated, + user: currentUser, + member: updatedMember, + ); + + // Dispatch event + client.addEvent(memberUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify membership is updated with new properties + expect(channel.membership, isNotNull); + expect(channel.membership?.userId, equals(currentUser?.id)); + expect(channel.membership?.channelRole, equals('moderator')); + expect(channel.membership?.isModerator, isTrue); + expect(channel.isPinned, isTrue); + expect(channel.isArchived, isTrue); + }, + ); + + test( + 'should update membership user when any event containing user is updated', + () async { + final currentUser = client.state.currentUser; + final currentMember = Member(user: currentUser); + + // Setup initial membership + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); + + // Verify initial state + expect(channel.membership, isNotNull); + expect(channel.membership?.user?.id, equals(currentUser?.id)); + expect(channel.membership?.user?.role, equals(currentUser?.role)); + + // Create updated user with same userId but updated properties + final updatedUser = currentUser?.copyWith(role: 'moderator'); + + // Create any event with same updated user as membership. + final anyEvent = Event( + cid: channel.cid, + type: EventType.any, + user: updatedUser, + ); + + // Dispatch event + client.addEvent(anyEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify membership is updated with new properties + expect(channel.membership, isNotNull); + expect(channel.membership?.user?.id, equals(updatedUser?.id)); + expect(channel.membership?.user?.role, equals(updatedUser?.role)); + }, + ); + }); + + group('Watching Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() => channel.dispose()); + + test( + '${EventType.userWatchingStart} adds the watcher and updates watcherCount', + () async { + final watcher = User(id: 'watcher-1'); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 3, + ), + ); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 3); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + }, + ); + + test( + '${EventType.userWatchingStop} removes the watcher and updates watcherCount', + () async { + final watcher = User(id: 'watcher-1'); + + // The watcher starts watching first (count = 2). + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStart, + user: watcher, + watcherCount: 2, + ), + ); + await Future.delayed(Duration.zero); + expect(channel.state!.watcherCount, 2); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + contains('watcher-1'), + ); + + // Then stops watching (count = 1). + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStop, + user: watcher, + watcherCount: 1, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 1); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + isNot(contains('watcher-1')), + ); + }, + ); + + test( + 'watching event without watcherCount preserves the existing count', + () async { + // Seed an initial watcher count. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(watcherCount: 5), + ); + expect(channel.state!.watcherCount, 5); + + // A watching event that omits watcher_count must not wipe the count. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.userWatchingStart, + user: User(id: 'watcher-2'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 5); + expect( + channel.state!.channelState.watchers?.map((it) => it.id), + contains('watcher-2'), + ); + }, + ); + + test( + '${EventType.messageNew} updates watcherCount from the event', + () async { + expect(channel.state!.watcherCount, isNull); + + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: DateTime.now(), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageNew, + message: message, + watcherCount: 7, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 7); + }, + ); + + test( + '${EventType.messageNew} without watcherCount preserves the existing count', + () async { + // Seed an initial watcher count. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(watcherCount: 4), + ); + expect(channel.state!.watcherCount, 4); + + // A local/optimistic message.new without watcher_count must not + // reset the count. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'test-message-id-2', + user: client.state.currentUser, + createdAt: DateTime.now(), + ), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 4); + }, + ); + + test( + '${EventType.notificationMessageNew} does not overwrite watcherCount', + () async { + // Seed a known watcher count. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith(watcherCount: 5), + ); + expect(channel.state!.watcherCount, 5); + + // notification.message_new is delivered to non-watchers and reports + // watcher_count: 0; it must not clobber the real count. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMessageNew, + message: Message( + id: 'notif-message-id', + user: User(id: 'other-user'), + createdAt: DateTime.now(), + ), + watcherCount: 0, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.watcherCount, 5); + }, + ); + }); + + group('Read Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ); + + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should update read state on message read event', () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + + // Create message read event + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(messageReadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); + }); + + test( + 'should add a new read state if not exist on message read event', + () async { + // Create the current read state + final currentUser = User(id: 'test-user'); + + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); + + // Create mark read notification event + final markReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(markReadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read list has not changed + final updated = channel.state?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); + }, + ); + + test( + 'should not update channel read state on thread message read event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastReadMessageId: 'channel-msg-1', + ); + + // Setup initial channel read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, 'channel-msg-1'); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + + // Create a thread-scoped message.read event (thread != null) + final threadMessageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'thread-reply-99', + thread: Thread( + channelCid: channel.cid!, + parentMessageId: 'parent-msg-1', + createdByUserId: currentUser.id, + replyCount: 3, + participantCount: 2, + ), + ); + + // Dispatch event + client.addEvent(threadMessageReadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Channel read state must be untouched — thread reads + // must not clobber the channel-level Read. + final after = channel.state?.read.first; + expect(after?.unreadMessages, 10); + expect(after?.lastReadMessageId, 'channel-msg-1'); + expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + }, + ); + + test('should update read state on notification mark unread event', () async { + // Create the current read state + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + + // Create mark unread notification event + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: currentUser, + lastReadAt: DateTime(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); + + // Dispatch event + client.addEvent(markUnreadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 15); + expect(updatedRead?.lastReadMessageId, 'message-100'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2019)), isTrue); + }); + + test( + 'should add a new read state if not exist on notification mark unread', + () async { + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); + + // Create event for non-existing user + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: User(id: 'non-existing-user'), + lastReadAt: DateTime(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); + + // Dispatch event + client.addEvent(markUnreadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read list has not changed + final updated = channel.state?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); + }, + ); + + test( + 'should preserve delivery info on message read event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastDeliveredAt: DateTime(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Setup initial read state with delivery info + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.lastDeliveredAt, isNotNull); + expect( + read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); + + // Create message read event (doesn't include delivery info) + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(messageReadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated but delivery info is preserved + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + // Delivery info should be preserved + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + test( + 'should reconcile delivery when message read event is from current user', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith(id: 'current-user-id'); + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + // Create message read event from current user + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); + + // Dispatch event + client.addEvent(messageReadEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + + test( + 'should reset unread count on notification mark read event', + () async { + final currentUser = client.state.currentUser!; + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + // Verify initial state + expect(channel.state?.unreadCount, 10); + + // notification.mark_read is delivered on the reading user's own + // connection, so it reaches non-watched channels as well. + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, currentUser.id); + expect(channel.state?.unreadCount, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + }, + ); + + test( + 'should preserve delivery info on notification mark read event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastDeliveredAt: DateTime(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated but delivery info is preserved + final updatedRead = channel.state?.read.first; + expect(updatedRead?.unreadMessages, 0); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); + + test( + 'should not update channel read state on thread notification mark ' + 'read event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastReadMessageId: 'channel-msg-1', + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'thread-reply-99', + thread: Thread( + channelCid: channel.cid!, + parentMessageId: 'parent-msg-1', + createdByUserId: currentUser.id, + replyCount: 3, + participantCount: 2, + ), + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Channel read state must be untouched — thread reads + // must not clobber the channel-level Read. + final after = channel.state?.read.first; + expect(after?.unreadMessages, 10); + expect(after?.lastReadMessageId, 'channel-msg-1'); + expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + }, + ); + + test( + 'should reconcile delivery when notification mark read event is from ' + 'current user', + () async { + final currentUser = client.state.currentUser; + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.notificationMarkRead, + user: currentUser, + createdAt: DateTime(2022), + lastReadMessageId: 'message-123', + ), + ); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + + test('should update read state on message delivered event', () async { + final currentUser = User(id: 'test-user'); + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + final currentRead = Read( + user: currentUser, + lastRead: distantPast, + unreadMessages: 5, + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state has no delivery info + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.lastDeliveredAt, isNull); + expect(read?.lastDeliveredMessageId, isNull); + + // Create message delivered event + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify delivery state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'message-456'); + }); + + test( + 'should add a new read state if not exist on message delivered event', + () async { + final newUser = User(id: 'new-user'); + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); + + // Create message delivered event for new user + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: newUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-789', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state was created with delivery info + final updated = channel.state?.read; + expect(updated?.length, 1); + final newRead = updated?.first; + expect(newRead?.user.id, 'new-user'); + expect(newRead?.lastDeliveredAt, isNotNull); + expect( + newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(newRead?.lastDeliveredMessageId, 'message-789'); + // lastRead should default to distantPast + expect( + newRead?.lastRead.isAtSameMomentAs(distantPast), + isTrue, + ); + }, + ); + + test( + 'should preserve read info on message delivered event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastReadMessageId: 'read-msg-123', + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); + + // Verify initial state + final read = channel.state?.read.first; + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, 'read-msg-123'); + + // Create message delivered event (doesn't include read info) + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'delivered-msg-456', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify delivery state is updated but read info is preserved + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + // Read info should be preserved + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), + isTrue, + ); + expect(updatedRead?.unreadMessages, 10); + expect(updatedRead?.lastReadMessageId, 'read-msg-123'); + }, + ); + + test( + 'should reconcile delivery when message delivered event is from current user', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith(id: 'current-user-id'); + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); + + // Create message delivered event from current user + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-456', + ); + + // Dispatch event + client.addEvent(messageDeliveredEvent); + + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + }); + + group('Draft events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle draft.updated event for channel drafts', () async { + // Verify initial state + expect(channel.state?.draft, isNull); + + // Create Draft + final draft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: draft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel draft was updated + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'test message'); + }); + + test('should handle draft.updated event for thread drafts', () async { + const threadParentMessageId = 'thread-parent-id'; + + // Setup initial state with a regular message + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + ), + ); + + // Verify initial state + expect(channel.state?.threadDraft(threadParentMessageId), isNull); + + // Create thread Draft + final draft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: draft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread draft was updated + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + }); + + test('should handle draft.deleted event for channel drafts', () async { + // Setup initial state with a draft + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + draft: Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ), + ), + ); + + // Verify initial state + final draft = channel.state?.draft; + expect(draft, isNotNull); + expect(draft?.message.text, 'test message'); + + // Create draft.deleted event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftDeleted, + draft: draft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel draft was updated + expect(channel.state?.draft, isNull); + }); + + test('should handle draft.deleted event for thread drafts', () async { + const threadParentMessageId = 'thread-parent-id'; + + // Setup initial state with a thread draft + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + draft: Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ), + ), + ); + + // Verify initial state + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + + // Create draft.deleted event + final draftDeletedEvent = Event( + cid: channel.cid, + type: EventType.draftDeleted, + draft: threadDraft, + ); + + // Dispatch event + client.addEvent(draftDeletedEvent); + + // Allow event to be processed + await Future.delayed(Duration.zero); + + // Verify thread draft was removed + expect(channel.state?.threadDraft(threadParentMessageId), isNull); + }); + + test( + 'should update current channel draft if draft.updated event is emitted', + () async { + // Setup initial state with a draft + final initialDraft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + draft: initialDraft, + ), + ); + + // Verify initial state + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'test message'); + + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated message'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel draft was updated + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'updated message'); + }, + ); + + test( + 'should update current thread draft if draft.updated event is emitted', + () async { + const threadParentMessageId = 'thread-parent-id'; + + // Setup initial state with a thread draft + final initialDraft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); + + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + draft: initialDraft, + ), + ); + + // Verify initial state + final draft = channel.state?.threadDraft(threadParentMessageId); + expect(draft, isNotNull); + expect(draft?.message.text, 'thread reply'); + + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated thread reply'), + ); + + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ); + + // Dispatch event + client.addEvent(draftUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread draft was updated + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'updated thread reply'); + }, + ); + }); + + group('Reminder events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle reminder.created event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message without reminder + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + ); + + channel.state?.updateMessage(message); + + // Verify initial state - no reminder + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: DateTime.now().add(const Duration(days: 30)), + ); + + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); + + // Dispatch event + client.addEvent(reminderCreatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was added + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }); + + test('should handle reminder.updated event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(message); + + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); + + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); + + // Create reminder.updated event + final reminderUpdatedEvent = Event( + cid: channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + // Dispatch event + client.addEvent(reminderUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was updated + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }); + + test('should handle reminder.deleted event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(message); + + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Create reminder.deleted event + final reminderDeletedEvent = Event( + cid: channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ); + + // Dispatch event + client.addEvent(reminderDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was removed + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }); + + test('should handle reminder.created event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message without reminder + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + // `Message.createdAt` falls back to `DateTime.now()` per call when + // not provided, which breaks merge/sort keyed on createdAt. + createdAt: DateTime.now(), + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state - no reminder + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); + + // Dispatch event + client.addEvent(reminderCreatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was added + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }); + + test('should handle reminder.updated event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, + // `Message.createdAt` falls back to `DateTime.now()` per call when + // not provided, which breaks merge/sort keyed on createdAt. + createdAt: DateTime.now(), + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); + + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); + + // Create reminder.updated event + final reminderUpdatedEvent = Event( + cid: channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + // Dispatch event + client.addEvent(reminderUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was updated + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }); + + test('should handle reminder.deleted event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, + // Explicit `createdAt` so `Message.createdAt` is deterministic + // across reads — without one it falls back to `DateTime.now()` + // on every call, which breaks any sort/merge keyed on createdAt. + createdAt: DateTime.now(), + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Create reminder.deleted event + final reminderDeletedEvent = Event( + cid: channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ); + + // Dispatch event + client.addEvent(reminderDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was removed + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }); + }); + + group('Location events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle location.shared event', () async { + // Verify initial state + expect(channel.state?.activeLiveLocations, isEmpty); + + // Create live location + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: locationMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message, isNotNull); + + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg1')); + }); + + test('should handle location.updated event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + channel.state?.addNewMessage(locationMessage); + + // Create updated location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + ); + + final updatedMessage = locationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Create location.updated event + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedMessage, + ); + + // Dispatch event + client.addEvent(locationUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.latitude, equals(40.7500)); + expect(message?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); + + test('should handle location.expired event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create expired location + final expiredLocation = liveLocation.copyWith( + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final expiredMessage = locationMessage.copyWith( + sharedLocation: expiredLocation, + ); + + // Create location.expired event + final locationExpiredEvent = Event( + cid: channel.cid, + type: EventType.locationExpired, + message: expiredMessage, + ); + + // Dispatch event + client.addEvent(locationExpiredEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.isExpired, isTrue); + + // Check if active live location was removed + expect(channel.state?.activeLiveLocations, isEmpty); + }); + + test('should not add static location to active locations', () async { + final staticLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + // No endAt - static location + ); + + final staticMessage = Message( + id: 'msg1', + text: 'Static location shared', + sharedLocation: staticLocation, + ); + + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: staticMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation, isNotNull); + + // Check if active live location was NOT updated (should remain empty) + expect(channel.state?.activeLiveLocations, isEmpty); + }); + + test( + 'should update active locations when location message is deleted', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Verify initial state + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + final messageDeletedEvent = Event( + type: EventType.messageDeleted, + cid: channel.cid, + message: locationMessage.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + ); + + // Dispatch event + client.addEvent(messageDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify active locations are updated + expect(channel.state?.activeLiveLocations, isEmpty); + }, + ); + + test('should merge locations with same key', () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create new location with same user, channel, and device + final newLocation = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', // Different message + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device1', // Same device + endAt: DateTime.now().add(const Duration(hours: 2)), + ); + + final newMessage = Message( + id: 'msg2', + text: 'Updated location', + sharedLocation: newLocation, + ); + + // Create location.shared event for the new message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: newMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Should still have only one active location (merged) + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg2')); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + }); + + test( + 'should handle multiple active locations from different devices', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add first location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create location from different device + final location2 = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device2', // Different device + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final message2 = Message( + id: 'msg2', + text: 'Location from device 2', + sharedLocation: location2, + ); + + // Create location.shared event for the second message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: message2, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Should have two active locations + expect(channel.state?.activeLiveLocations, hasLength(2)); + }, + ); + + test('should handle location messages in threads', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + // Add parent message first for setup + channel.state?.addNewMessage(parentMessage); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Create location.shared event for the thread message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: threadLocationMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was added + final thread = channel.state?.threads['parent1']; + expect(thread, contains(threadLocationMessage)); + + // Check if location was added to active locations + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); + }); + + test('should update thread location messages', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Add messages + channel.state?.addNewMessage(parentMessage); + channel.state?.addNewMessage(threadLocationMessage); + + // Update the location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, + longitude: -74.1000, + ); + + final updatedThreadMessage = threadLocationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Create location.updated event for the thread message + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedThreadMessage, + ); + + // Dispatch event + client.addEvent(locationUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was updated + final thread = channel.state?.threads['parent1']; + final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); + expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); + expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); + }); + + group('Channel push preference events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle channel.push_preference.updated event', () async { + // Verify initial state + expect(channel.state?.channelState.pushPreferences, isNull); + + // Create channel push preference + final channelPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.mentions, + disabledUntil: DateTime.now().add(const Duration(hours: 1)), + ); + + // Create channel.push_preference.updated event + final channelPushPreferenceUpdatedEvent = Event( + cid: channel.cid, + type: EventType.channelPushPreferenceUpdated, + channelPushPreference: channelPushPreference, + ); + + // Dispatch event + client.addEvent(channelPushPreferenceUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel push preferences were updated + final updatedPreferences = channel.state?.channelState.pushPreferences; + expect(updatedPreferences, isNotNull); + expect(updatedPreferences?.chatLevel, ChatLevel.mentions); + expect( + updatedPreferences?.disabledUntil, + channelPushPreference.disabledUntil, + ); + }); + + test('should update existing channel push preferences', () async { + // Set initial push preferences + const initialPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.all, + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + pushPreferences: initialPushPreference, + ), + ); + + // Verify initial state + final pushPreferences = channel.state?.channelState.pushPreferences; + expect(pushPreferences?.chatLevel, ChatLevel.all); + expect(pushPreferences?.disabledUntil, isNull); + + // Create updated channel push preference + final updatedPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.none, + disabledUntil: DateTime.now().add(const Duration(hours: 2)), + ); + + // Create channel.push_preference.updated event + final channelPushPreferenceUpdatedEvent = Event( + cid: channel.cid, + type: EventType.channelPushPreferenceUpdated, + channelPushPreference: updatedPushPreference, + ); + + // Dispatch event + client.addEvent(channelPushPreferenceUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify channel push preferences were updated + final updatedPreferences = channel.state?.channelState.pushPreferences; + expect(updatedPreferences?.chatLevel, ChatLevel.none); + expect( + updatedPreferences?.disabledUntil, + updatedPushPreference.disabledUntil, + ); + }); + }); + + group('User messages deleted event', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + late MockPersistenceClient persistenceClient; + + setUp(() { + persistenceClient = MockPersistenceClient(); + when(() => client.chatPersistenceClient).thenReturn(persistenceClient); + when( + () => persistenceClient.deleteMessagesFromUser( + cid: any(named: 'cid'), + userId: any(named: 'userId'), + hardDelete: any(named: 'hardDelete'), + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async {}); + when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => >{}); + + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test( + 'should soft delete all messages from user when hardDelete is false', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-1').length, + equals(2), + ); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-2').length, + equals(1), + ); + + // Create user.messages.deleted event (soft delete) + final deletedAt = DateTime.now(); + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + createdAt: deletedAt, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are soft deleted + expect(channel.state?.messages.length, equals(3)); + final deletedMessages = channel.state?.messages.where((m) => m.user?.id == 'user-1').toList(); + expect(deletedMessages?.length, equals(2)); + for (final message in deletedMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.deletedAt, isNotNull); + expect(message.state.isDeleted, isTrue); + } + + // Verify user2's message is unaffected + final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message?.type, isNot(MessageType.deleted)); + expect(user2Message?.deletedAt, isNull); + }, + ); + + test( + 'should hard delete all messages from user when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are removed + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify user2's message still exists + final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message, isNotNull); + expect(user2Message?.user?.id, equals('user-2')); + }, + ); + + test( + 'should handle thread messages from user', + () async { + // Setup: Add parent and thread messages + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentMessage = Message( + id: 'parent-msg', + text: 'Parent message', + user: user2, + ); + final threadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + final threadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + + channel.state?.addNewMessage(parentMessage); + channel.state?.addNewMessage(threadMessage1); + channel.state?.addNewMessage(threadMessage2); + + // Verify initial state + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.threads['parent-msg']?.length, equals(2)); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread messages are soft deleted + final threadMessages = channel.state?.threads['parent-msg']; + expect(threadMessages?.length, equals(2)); + for (final message in threadMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.state.isDeleted, isTrue); + } + + // Verify parent message is unaffected + final parent = channel.state?.messages.first; + expect(parent?.type, isNot(MessageType.deleted)); + }, + ); + + test( + 'should do nothing when user is null', + () async { + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + channel.state?.addNewMessage(message1); + + // Verify initial state + expect(channel.state?.messages.length, equals(1)); + + // Create user.messages.deleted event without user + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify messages are unaffected + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.first.type, + isNot(MessageType.deleted), + ); + }, + ); + + test( + 'should handle empty message list', + () async { + // Setup: Empty channel + expect(channel.state?.messages.length, equals(0)); + + // Create user.messages.deleted event + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: User(id: 'user-1'), + hardDelete: false, + ); + + // Dispatch event - should not throw + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify state is still empty + expect(channel.state?.messages.length, equals(0)); + }, + ); + + test( + 'should delete messages from persistence when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify messages are removed from persistence + verify( + () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), + ).called(1); + verify( + () => persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), + ).called(1); + + // Verify user1's messages are removed from state + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + }, + ); + + test( + 'should not delete from persistence when hardDelete is false', + () async { + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + + channel.state?.addNewMessage(message1); + + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify persistence deletion methods were NOT called + verifyNever(() => persistenceClient.deleteMessageByIds(any())); + verifyNever(() => persistenceClient.deletePinnedMessageByIds(any())); + + // Verify message is soft deleted (still in state) + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.messages.first.type, equals(MessageType.deleted)); + }, + ); + + test( + 'should delete all user messages including those only in storage', + () async { + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final stateMessage1 = Message( + id: 'msg-1', + text: 'Message from user 1 in state', + user: user1, + pinned: true, + ); + final stateMessage2 = Message( + id: 'msg-2', + text: 'Message from user 2 in state', + user: user2, + ); + final stateThreadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1 in state', + user: user1, + parentId: 'msg-1', + ); + final stateThreadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 2 in state', + user: user2, + parentId: 'msg-1', + ); + + // Load the state with only 2 messages and 1 thread with 2 replies. + // Note: In reality, storage may contain many more user1 messages + // (e.g., older messages not loaded into state yet), but the delete + // operation should remove ALL of them from storage. + channel.state?.addNewMessage(stateMessage1); + channel.state?.addNewMessage(stateMessage2); + channel.state?.addNewMessage(stateThreadMessage1); + channel.state?.addNewMessage(stateThreadMessage2); + + // Verify initial state has only 2 messages and 1 thread with 2 replies + expect(channel.state?.messages.length, equals(2)); + expect(channel.state?.threads['msg-1']?.length, equals(2)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are removed from state + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.threads['msg-1']?.length, equals(1)); + + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + expect( + channel.state?.threads['msg-1']?.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify persistence delete was called - this handles ALL messages + // in storage (both those in state AND those only in storage) + verify( + () => persistenceClient.deleteMessagesFromUser( + cid: channel.cid, + userId: user1.id, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + + // Verify in-state messages were also removed from state's persistence + final capturedIds = + verify( + () => persistenceClient.deleteMessageByIds(captureAny()), + ).captured.first + as List; + + expect( + capturedIds, + containsAll([ + 'msg-1', // state message + 'thread-msg-1', // state thread message + ]), + ); + }, + ); + + test( + 'should delete every authored message across threads without ' + 'cross-thread leakage (regression: _updateThreadMessages)', + () async { + // user-1 authors a top-level message AND replies in two different + // threads (owned by user-2). The user.messages.deleted flow + // collects everything from user-1 across channel + threads and + // routes it through a single _updateMessages batch — historically + // this batch was passed unfiltered to every affected thread's + // merge, so replies to thread A leaked into thread B and v.v. + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); + final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); + + final topLevelFromUser1 = Message( + id: 'top-1', + text: 'user-1 top-level message', + user: user1, + ); + final replyA = Message( + id: 'reply-A', + text: 'user-1 reply in thread A', + user: user1, + parentId: 'parent-A', + ); + final replyB = Message( + id: 'reply-B', + text: 'user-1 reply in thread B', + user: user1, + parentId: 'parent-B', + ); + + channel.state?.addNewMessage(parentA); + channel.state?.addNewMessage(parentB); + channel.state?.addNewMessage(topLevelFromUser1); + channel.state?.addNewMessage(replyA); + channel.state?.addNewMessage(replyB); + + // Initial state: each thread has exactly its own reply. + expect( + channel.state?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + ); + expect( + channel.state?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + ); + + // Trigger the multi-thread batch via user.messages.deleted. + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); + client.addEvent(userMessagesDeletedEvent); + await Future.delayed(Duration.zero); + + // 1) Thread membership is preserved — no cross-thread leakage. + // Without the fix, replyB would leak into thread A and v.v. + expect( + channel.state?.threads['parent-A']?.map((m) => m.id), + equals(['reply-A']), + reason: 'thread A must not contain replies from thread B', + ); + expect( + channel.state?.threads['parent-B']?.map((m) => m.id), + equals(['reply-B']), + reason: 'thread B must not contain replies from thread A', + ); + + // 2) Every message authored by user-1 is soft-deleted — top-level + // AND in both threads. The fix must not narrow this scope. + expect( + channel.state?.messages.firstWhere((m) => m.id == 'top-1').type, + equals(MessageType.deleted), + reason: 'top-level user-1 message must be deleted', + ); + expect( + channel.state?.threads['parent-A']?.first.type, + equals(MessageType.deleted), + reason: 'thread A reply from user-1 must be deleted', + ); + expect( + channel.state?.threads['parent-B']?.first.type, + equals(MessageType.deleted), + reason: 'thread B reply from user-1 must be deleted', + ); + + // 3) Other users' messages are unaffected. + expect( + channel.state?.messages.firstWhere((m) => m.id == 'parent-A').type, + isNot(MessageType.deleted), + ); + expect( + channel.state?.messages.firstWhere((m) => m.id == 'parent-B').type, + isNot(MessageType.deleted), + ); + }, + ); + }); + }); + + group('Local unread count', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + final currentUser = OwnUser(id: 'current-user-id'); + + late final client = MockStreamChatClient(); + + setUpAll(() { + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + when(() => client.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, __, ___) => false, delayFactor: Duration.zero), + ); + when(() => client.state).thenReturn(FakeClientState(currentUser: currentUser)); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + when( + () => client.channelDeliveryReporter.reconcileDelivery(any()), + ).thenAnswer((_) async {}); + client.isLocalUnreadCountEnabled = true; + }); + + // A "livestream-like" channel: read events are disabled, both via the + // channel-type config and the current user's own capabilities. + Channel _createLivestreamChannel({ + StreamChatClient? overrideClient, + List? messages, + List? reads, + }) { + final channelState = ChannelState( + channel: ChannelModel( + id: channelId, + type: channelType, + config: ChannelConfig(readEvents: false), + ownCapabilities: const [], // No readEvents capability. + ), + messages: messages, + read: reads, + ); + + final channel = Channel.fromState(overrideClient ?? client, channelState); + addTearDown(channel.dispose); + return channel; + } + + test( + 'increments unreadCount locally for new messages when the channel has ' + 'no read events capability', + () async { + final channel = _createLivestreamChannel(); + expect(channel.state?.unreadCount, equals(0)); + + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + + client.addEvent( + Event(cid: channel.cid, type: EventType.messageNew, message: message), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(1)); + }, + ); + + test( + 'does not increment unreadCount when local unread count tracking is ' + 'disabled', + () async { + final disabledClient = MockStreamChatClient(); + when(() => disabledClient.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + when(() => disabledClient.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, __, ___) => false), + ); + when(() => disabledClient.state).thenReturn(FakeClientState(currentUser: currentUser)); + when(() => disabledClient.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => disabledClient.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + // `isLocalUnreadCountEnabled` defaults to `false` on the mock. + + final channel = _createLivestreamChannel(overrideClient: disabledClient); + + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + + disabledClient.addEvent( + Event(cid: channel.cid, type: EventType.messageNew, message: message), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + + test('decrements unreadCount when a counted message is hard-deleted', () async { + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + final channel = _createLivestreamChannel( + messages: [message], + reads: [ + Read( + user: currentUser, + lastRead: message.createdAt.subtract(const Duration(days: 1)), + ), + ], + ); + channel.state!.unreadCount = 1; + expect(channel.state?.unreadCount, equals(1)); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: message, + hardDelete: true, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(0)); + }); + + test('does not decrement unreadCount when a message is soft-deleted', () async { + final message = Message( + id: 'message-1', + text: 'Hello', + user: User(id: 'other-user'), + createdAt: DateTime(2024, 1, 1), + ); + final channel = _createLivestreamChannel( + messages: [message], + reads: [ + Read( + user: currentUser, + lastRead: message.createdAt.subtract(const Duration(days: 1)), + ), + ], + ); + channel.state!.unreadCount = 1; + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: message, + hardDelete: false, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state?.unreadCount, equals(1)); + }); + + test( + 'markRead resets unreadCount locally without making a network request', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 3; + expect(channel.state?.unreadCount, equals(3)); + + await expectLater(channel.markRead(), completes); + + expect(channel.state?.unreadCount, equals(0)); + verifyNever( + () => client.markChannelRead( + any(), + any(), + messageId: any(named: 'messageId'), + ), + ); + }, + ); + + test( + 'markUnreadByTimestamp recomputes unreadCount locally without making a ' + 'network request', + () async { + final now = DateTime(2024, 1, 1); + final messages = [ + Message( + id: 'm1', + text: '1', + user: User(id: 'other-user'), + createdAt: now, + ), + Message( + id: 'm2', + text: '2', + user: User(id: 'other-user'), + createdAt: now.add(const Duration(minutes: 1)), + ), + Message( + id: 'm3', + text: '3', + user: User(id: 'other-user'), + createdAt: now.add(const Duration(minutes: 2)), + ), + ]; + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: now.add(const Duration(minutes: 5))), + ], + ); + expect(channel.state?.unreadCount, equals(0)); + + await expectLater( + channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), + completes, + ); + + // Only m2 and m3 were created after the given timestamp. + expect(channel.state?.unreadCount, equals(2)); + verifyNever( + () => client.markChannelUnreadByTimestamp(any(), any(), any()), + ); + }, + ); + + test( + 'markUnread throws when the message is not locally known', + () async { + final channel = _createLivestreamChannel(); + + await expectLater( + channel.markUnread('unknown-message-id'), + throwsA(isA()), + ); + verifyNever( + () => client.markChannelUnread(any(), any(), any()), + ); + }, + ); + + test( + 'markRead reconciles pending delivery receipts', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 2; + + await expectLater(channel.markRead(), completes); + + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + + group('local read boundary anchors', () { + final start = DateTime(2024, 1, 1); + final messages = [ + Message( + id: 'm1', + text: '1', + user: User(id: 'other-user'), + createdAt: start, + ), + Message( + id: 'm2', + text: '2', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 1)), + ), + Message( + id: 'm3', + text: '3', + user: User(id: 'other-user'), + createdAt: start.add(const Duration(minutes: 2)), + ), + ]; + + test( + 'markUnread is inclusive of the anchor and points lastReadMessageId at ' + 'the previous message', + () async { + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + await expectLater(channel.markUnread('m2'), completes); + + // m2 (the anchor) and m3 are unread; m1 stays read. + expect(channel.state?.unreadCount, equals(2)); + expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m1')); + verifyNever(() => client.markChannelUnread(any(), any(), any())); + }, + ); + + test( + 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' + 'known message', + () async { + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + await expectLater(channel.markUnread('m1'), completes); + + expect(channel.state?.unreadCount, equals(3)); + expect(channel.state?.currentUserRead?.lastReadMessageId, isNull); + }, + ); + + test( + 'markUnreadByTimestamp is exclusive of the boundary and points ' + 'lastReadMessageId at the newest message at or before it', + () async { + final channel = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + // Exactly m2's createdAt: m2 stays read, only m3 becomes unread. + await expectLater(channel.markUnreadByTimestamp(messages[1].createdAt), completes); + + expect(channel.state?.unreadCount, equals(1)); + expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m2')); + verifyNever(() => client.markChannelUnreadByTimestamp(any(), any(), any())); + }, + ); + + test( + 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' + 'differ by the anchor message', + () async { + final byId = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + final byTimestamp = _createLivestreamChannel( + messages: messages, + reads: [ + Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), + ], + ); + + await byId.markUnread('m2'); + await byTimestamp.markUnreadByTimestamp(messages[1].createdAt); + + // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. + expect(byId.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.unreadCount, equals(1)); + + // ...and they agree once the timestamp is nudged below the anchor. + await byTimestamp.markUnreadByTimestamp( + messages[1].createdAt.subtract(const Duration(microseconds: 1)), + ); + expect(byTimestamp.state?.unreadCount, equals(2)); + expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); + }, + ); + }); + + test( + 'server payloads do not clobber the locally-tracked read state', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 5; + + final serverRead = Read( + user: currentUser, + lastRead: DateTime.now(), + unreadMessages: 0, + ); + channel.state!.updateChannelStateFromServer( + channel.state!.channelState.copyWith(read: [serverRead]), + ); + + expect(channel.state?.unreadCount, equals(5)); + }, + ); + + test( + 'local (non-remote) state updates are not affected by the server-merge ' + 'guard', + () async { + final channel = _createLivestreamChannel(); + channel.state!.unreadCount = 5; + + // A plain local mutation (via updateChannelState, not + // updateChannelStateFromServer) should still be able to change the + // locally-tracked read state. + await expectLater(channel.markRead(), completes); + + expect(channel.state?.unreadCount, equals(0)); + }, + ); + }); + + group('updateChannelState identity guard', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); + + setUpAll(() { + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + when(() => client.retryPolicy).thenReturn( + RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ), + ); + when(() => client.state).thenReturn(FakeClientState()); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + }); + + Channel _seededChannel() { + final base = _generateChannelState(channelId, channelType); + final now = DateTime.now(); + final seeded = base.copyWith( + messages: [ + Message(id: 'm1', text: '1', createdAt: now), + Message(id: 'm2', text: '2', createdAt: now.add(const Duration(seconds: 1))), + Message(id: 'm3', text: '3', createdAt: now.add(const Duration(seconds: 2))), + ], + ); + return Channel.fromState(client, seeded); + } + + test( + 'preserves messages reference when updatedState.messages is null', + () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final before = channel.state!.messages; + channel.state!.updateChannelState( + ChannelState(channel: channel.state!.channelState.channel), + ); + final after = channel.state!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + test( + 'preserves messages reference when updatedState.messages is identical', + () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final before = channel.state!.messages; + // copyWith without messages keeps the same `messages` reference, so + // updateChannelState should hit the identity-guard fast path. + channel.state!.updateChannelState( + channel.state!.channelState.copyWith( + read: [ + Read( + user: User(id: 'me'), + lastRead: DateTime.now(), + unreadMessages: 1, + ), + ], + ), + ); + final after = channel.state!.messages; + + expect(identical(before, after), isTrue); + }, + ); + + test( + 'still merges messages when updatedState.messages is a different list', + () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final newMessage = Message( + id: 'm4', + text: '4', + createdAt: DateTime.now().add(const Duration(seconds: 10)), + ); + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + messages: [newMessage], + ), + ); + + expect( + channel.state!.messages.map((m) => m.id), + ['m1', 'm2', 'm3', 'm4'], + ); + }, + ); + + test('cold-path merge interleaves new messages in sorted order', () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final base = channel.state!.messages.first.createdAt; + // Incoming list is sorted ascending by createdAt and slots between + // the existing m1, m2, m3. + final incoming = [ + Message( + id: 'm1.5', + text: 'between m1 and m2', + createdAt: base.add(const Duration(milliseconds: 500)), + ), + Message( + id: 'm2.5', + text: 'between m2 and m3', + createdAt: base.add(const Duration(milliseconds: 1500)), + ), + ]; + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + messages: incoming, + ), + ); + + expect( + channel.state!.messages.map((m) => m.id), + ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], + ); + }); + + test('cold-path merge runs syncWith on overlapping ids', () { + final channel = _seededChannel(); + addTearDown(channel.dispose); + + final localStamp = DateTime.now(); + // Seed m2 with a localCreatedAt that the incoming version doesn't + // carry, so we can verify syncWith fired during the merge. + channel.state!.updateMessage( + Message( + id: 'm2', + text: '2', + createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ).copyWith(localCreatedAt: localStamp), + ); + + final incoming = [ + Message( + id: 'm2', + text: '2 (server)', + createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, + ), + ]; + channel.state!.updateChannelState( + ChannelState( + channel: channel.state!.channelState.channel, + messages: incoming, + ), + ); + + final m2 = channel.state!.messages.firstWhere((m) => m.id == 'm2'); + expect(m2.text, '2 (server)'); + // Local-only field carried over by syncWith during the merge. + expect(m2.localCreatedAt, localStamp); + }); + }); + + group('updateMessage quoted-rewrite', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); + + setUpAll(() { + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + when(() => client.retryPolicy).thenReturn( + RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ), + ); + when(() => client.state).thenReturn(FakeClientState()); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + }); + + Channel _seededChannel({required List messages}) { + final base = _generateChannelState(channelId, channelType); + return Channel.fromState(client, base.copyWith(messages: messages)); + } + + test( + 'rewrites quotedMessage on every quoter when target is deleted', + () { + final now = DateTime.now(); + final target = Message(id: 'target', text: 'hi', createdAt: now); + final quoter1 = Message( + id: 'q1', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + final unrelated = Message( + id: 'u1', + text: 'other', + createdAt: now.add(const Duration(seconds: 2)), + ); + final quoter2 = Message( + id: 'q2', + text: 'reply2', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 3)), + ); + + final channel = _seededChannel(messages: [target, quoter1, unrelated, quoter2]); + addTearDown(channel.dispose); + + final unrelatedBefore = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + channel.state!.updateMessage(deleted); + + final after = channel.state!.messages; + final q1After = after.firstWhere((m) => m.id == 'q1'); + final q2After = after.firstWhere((m) => m.id == 'q2'); + final uAfter = after.firstWhere((m) => m.id == 'u1'); + + expect(q1After.quotedMessage?.deletedAt, isNotNull); + expect(q1After.quotedMessage?.type, MessageType.deleted); + expect(q2After.quotedMessage?.deletedAt, isNotNull); + expect(q2After.quotedMessage?.type, MessageType.deleted); + // Unrelated messages must not be rebuilt by the rewrite. + expect(identical(uAfter, unrelatedBefore), isTrue); + }, + ); + + test( + 'preserves messages reference when no message quotes the deleted one', + () { + final now = DateTime.now(); + final target = Message(id: 'target', text: 'hi', createdAt: now); + final unrelated = Message( + id: 'u1', + text: 'other', + createdAt: now.add(const Duration(seconds: 1)), + ); + + final channel = _seededChannel(messages: [target, unrelated]); + addTearDown(channel.dispose); + + final deleted = target.copyWith( + type: MessageType.deleted, + deletedAt: now.add(const Duration(seconds: 5)), + ); + channel.state!.updateMessage(deleted); + + // No message quotes `target`, so `updateIf` short-circuits and the + // remaining messages keep their identities (only `target` itself was + // replaced by `sortedUpsert`). + final unrelatedAfter = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + expect(identical(unrelatedAfter, unrelated), isTrue); + }, + ); + + test( + 'does not rewrite quotes when an existing quoted target is updated ' + 'without being deleted', + () { + final now = DateTime.now(); + final target = Message(id: 'target', text: 'original', createdAt: now); + final quoter = Message( + id: 'q1', + text: 'reply', + quotedMessageId: 'target', + quotedMessage: target, + createdAt: now.add(const Duration(seconds: 1)), + ); + + final channel = _seededChannel(messages: [target, quoter]); + addTearDown(channel.dispose); + + final quoterBefore = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + + // Plain text update — not a deletion. + channel.state!.updateMessage(target.copyWith(text: 'edited')); + + final quoterAfter = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + // `updateIf` is gated on `message.isDeleted`, so the quoter must keep + // its identity (no allocation, no quoted-message overwrite). + expect(identical(quoterAfter, quoterBefore), isTrue); + }, + ); + }); + + group('Message enrichment preservation on merge', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUpAll(() { + registerFallbackValue(FakeMessage()); + registerFallbackValue([]); + + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + }); + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + clearInteractions(client); + }); + + test( + 'preserves the `poll` on a quotedMessage when the server omits it during ' + 're-sync (regression: poll quote disappears after foregrounding)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-1', + name: 'Pizza or pasta?', + options: const [ + PollOption(id: 'opt-1', text: 'Pizza'), + PollOption(id: 'opt-2', text: 'Pasta'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-1', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-1', + text: 'Voting now', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + // Seed channel state with the fully-enriched messages (mirrors what + // the local DB load produces). + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate a re-sync from the API: the server echoes the reply with + // a `quoted_message` that has only `poll_id` (no `poll` object). + // Constructed directly (not via copyWith) because copyWith cannot + // clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.id, pollMessage.id); + expect(mergedReply.quotedMessage!.poll, isNotNull); + expect(mergedReply.quotedMessage!.poll!.id, poll.id); + expect(mergedReply.quotedMessage!.poll!.name, poll.name); + }, + ); + + test( + 'preserves a nested quotedMessage (poll) two levels deep when the ' + 'server omits it during re-sync (regression: quote-of-quote of a poll ' + 'disappears completely after foregrounding)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-2', + name: 'Coffee or tea?', + options: const [ + PollOption(id: 'opt-a', text: 'Coffee'), + PollOption(id: 'opt-b', text: 'Tea'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-2', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-A', + text: 'My pick', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'user-a'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + final replyToReply = Message( + id: 'reply-B', + text: 'Same here', + quotedMessageId: replyToPoll.id, + quotedMessage: replyToPoll, + user: User(id: 'user-b'), + createdAt: DateTime.utc(2026, 4, 29, 12), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll, replyToReply], + ), + ); + + // Simulate the server response where: + // - replyA's nested quoted poll is missing the `poll` object. + // - replyB's nested quoted replyA is missing its own `quoted_message` + // (the server typically does not nest two levels deep). + // Stripped poll snapshot is constructed directly because copyWith + // cannot clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + ); + final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); + + final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], + ), + ); + + final mergedReplyA = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + final mergedReplyB = channel.state?.messages.firstWhere((it) => it.id == replyToReply.id); + + // First-level quote (reply A's quote of the poll) must keep the poll. + expect(mergedReplyA?.quotedMessage?.poll, isNotNull); + expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); + + // Second-level quote (reply B's quote of reply A) must keep reply A's + // own nested quotedMessage so the poll preview still resolves. + expect(mergedReplyB?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); + }, + ); + + test( + 'still preserves quotedMessage when the updated payload has no ' + 'quoted_message at all (existing behavior should not regress)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-3', + name: 'Beach or mountains?', + options: const [ + PollOption(id: 'opt-x', text: 'Beach'), + PollOption(id: 'opt-y', text: 'Mountains'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'poll-msg-3', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + final replyToPoll = Message( + id: 'reply-3', + text: 'Definitely beach', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll], + ), + ); + + // Simulate an update event that touches the reply but doesn't echo + // the nested quoted_message at all (only quotedMessageId is set). + final reSyncedReply = Message( + id: replyToPoll.id, + text: 'Definitely beach (edited)', + quotedMessageId: pollMessage.id, + user: replyToPoll.user, + createdAt: replyToPoll.createdAt, + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [reSyncedReply], + ), + ); + + final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.text, 'Definitely beach (edited)'); + expect(mergedReply.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.poll?.id, poll.id); + }, + ); + + test( + 'preserves the top-level `poll` when the server emits a `message.updated`' + ' that omits the `poll` object (regression: poll disappears from the ' + 'parent message after a thread reply is added)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-thread', + name: 'What is for lunch?', + options: const [ + PollOption(id: 'opt-1', text: 'Burgers'), + PollOption(id: 'opt-2', text: 'Salads'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'parent-poll-msg', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + replyCount: 0, + ); + + // Seed channel state with the fully-enriched parent poll message. + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + // Simulate the `message.updated` event the backend fires for the + // parent after a thread reply is added: bookkeeping fields are bumped + // (`reply_count`, `updated_at`) but the `poll` object is omitted from + // the payload — only `pollId` is set. Constructed directly because + // copyWith cannot clear `poll` — see Message.copyWith. + final strippedParentUpdate = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + replyCount: 1, + updatedAt: DateTime.utc(2026, 4, 29, 11), + ); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: strippedParentUpdate, + ), + ); + + // Wait for the event to be processed. + await Future.delayed(Duration.zero); + + final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + + // Parent poll message must remain in the channel state after a thread reply. + expect(merged, isNotNull); + // Bookkeeping fields from the event should still apply. + expect(merged!.replyCount, 1); + // Locally-known poll must be preserved when the server omits it from a + // `message.updated` payload (e.g. when a thread reply bumps reply_count). + expect(merged.poll, isNotNull); + expect(merged.poll!.id, poll.id); + expect(merged.poll!.name, poll.name); + expect(merged.pollId, poll.id); + }, + ); + + test( + 'still uses the updated `poll` when the server includes one in ' + '`message.updated` (poll edits should not be reverted to the locally ' + 'cached version)', + () async { + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-edit', + name: 'Initial name', + options: const [ + PollOption(id: 'opt-1', text: 'Original A'), + ], + createdById: pollUser.id, + ); + + final pollMessage = Message( + id: 'edit-parent', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); + + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage], + ), + ); + + final updatedPoll = poll.copyWith(name: 'Edited name'); + final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: updatedParent, + ), + ); + + await Future.delayed(Duration.zero); + + final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + + // Server-echoed poll must override the locally cached one — poll edits + // should not be reverted by the local-fallback merge. + expect(merged?.poll, isNotNull); + expect(merged?.poll?.name, 'Edited name'); + }, + ); + }); +} + +// region Test Helpers + +ChannelState _generateChannelState( + String channelId, + String channelType, { + DateTime? lastMessageAt, + List? ownCapabilities, + bool mockChannelConfig = false, +}) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ownCapabilities: ownCapabilities, + lastMessageAt: lastMessageAt, + ); + return ChannelState(channel: channel); +} + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +// endregion diff --git a/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart b/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart new file mode 100644 index 0000000000..ecda0d62d5 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_read_helper_test.dart @@ -0,0 +1,319 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../mocks.dart'; + +void main() { + group('ChannelReadHelper', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); + + // A date in the distant past (Unix epoch), useful for representing old dates + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); + + test('userReadOf should return read for specific user', () { + final now = DateTime.now(); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final reads = [ + Read(user: user1, lastRead: now), + Read(user: user2, lastRead: now.add(const Duration(minutes: 1))), + ]; + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); + + final user1Read = channel.state!.userReadOf(userId: 'user-1'); + expect(user1Read, isNotNull); + expect(user1Read!.user.id, 'user-1'); + expect(user1Read.lastRead, now); + + final user2Read = channel.state!.userReadOf(userId: 'user-2'); + expect(user2Read, isNotNull); + expect(user2Read!.user.id, 'user-2'); + + final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); + expect(nonExistentRead, isNull); + }); + + test('userReadOf should return null when userId is null', () { + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final read = channel.state!.userReadOf(userId: null); + expect(read, isNull); + }); + + test( + 'userReadStreamOf should emit read updates for specific user', + () async { + final now = DateTime.now(); + final user1 = User(id: 'user-1', name: 'User 1'); + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); + + expectLater( + readStream, + emitsInOrder([ + isNull, // initial state + isA().having((r) => r.user.id, 'userId', 'user-1'), + ]), + ); + + // Update with read + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [Read(user: user1, lastRead: now)], + ), + ); + }, + ); + + test('readsOf should return reads that have marked message as read', () { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + final user3 = User(id: 'user-3', name: 'User 3'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final reads = [ + // user1 has read the message + Read(user: user1, lastRead: now.add(const Duration(seconds: 1))), + // user2 has not read the message yet + Read(user: user2, lastRead: distantPast), + // user3 has read the message + Read(user: user3, lastRead: now.add(const Duration(seconds: 2))), + // sender should be excluded + Read(user: sender, lastRead: now.add(const Duration(seconds: 10))), + ]; + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); + + final messageReads = channel.state!.readsOf(message: message); + expect(messageReads.length, 2); + expect(messageReads.map((r) => r.user.id), containsAll(['user-1', 'user-3'])); + expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); + expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); + }); + + test('readsOfStream should emit read updates for a message', () async { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final readsStream = channel.state!.readsOfStream(message: message); + + expectLater( + readsStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding read + ]), + ); + + // Update with read + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [Read(user: user1, lastRead: now.add(const Duration(seconds: 1)))], + ), + ); + }); + + test('deliveriesOf should return reads that have delivered the message', () { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + final user3 = User(id: 'user-3', name: 'User 3'); + final user4 = User(id: 'user-4', name: 'User 4'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final reads = [ + // user1 has delivered the message + Read( + user: user1, + lastRead: distantPast, + lastDeliveredAt: now.add(const Duration(seconds: 1)), + ), + // user2 has not delivered the message yet (lastDeliveredAt is before message) + Read( + user: user2, + lastRead: distantPast, + lastDeliveredAt: distantPast, + ), + // user3 has no lastDeliveredAt + Read( + user: user3, + lastRead: distantPast, + ), + // user4 has read the message (implicitly delivered) + Read( + user: user4, + lastRead: now.add(const Duration(seconds: 1)), + ), + // sender should be excluded + Read( + user: sender, + lastRead: now.add(const Duration(seconds: 10)), + lastDeliveredAt: now.add(const Duration(seconds: 10)), + ), + ]; + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); + + final deliveries = channel.state!.deliveriesOf(message: message); + expect(deliveries.length, 2); + expect(deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); + }); + + test('deliveriesOfStream should emit delivery updates for a message', () async { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); + + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final deliveriesStream = channel.state!.deliveriesOfStream(message: message); + + expectLater( + deliveriesStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding delivery + ]), + ); + + // Update with delivery + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [ + Read( + user: user1, + lastRead: distantPast, + lastDeliveredAt: now.add(const Duration(seconds: 1)), + ), + ], + ), + ); + }); + }); +} + +// region Test Helpers + +ChannelState _generateChannelState( + String channelId, + String channelType, { + DateTime? lastMessageAt, + List? ownCapabilities, + bool mockChannelConfig = false, +}) { + ChannelConfig? config; + if (mockChannelConfig) { + config = MockChannelConfig(); + when(() => config!.readEvents).thenReturn(true); + when(() => config!.typingEvents).thenReturn(true); + } + final channel = ChannelModel( + id: channelId, + type: channelType, + config: config, + ownCapabilities: ownCapabilities, + lastMessageAt: lastMessageAt, + ); + return ChannelState(channel: channel); +} + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +// endregion diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart similarity index 51% rename from packages/stream_chat/test/src/client/channel_test.dart rename to packages/stream_chat/test/src/client/channel/channel_test.dart index 0a71f7cdbc..d7f3b27a86 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -4,9 +4,9 @@ import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -import '../fakes.dart'; -import '../matchers.dart'; -import '../mocks.dart'; +import '../../fakes.dart'; +import '../../matchers.dart'; +import '../../mocks.dart'; void main() { ChannelState _generateChannelState( @@ -4947,15 +4947,12 @@ void main() { }); }); - group('WS events', () { + group('Channel State Validation and Cooldown', () { late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); - // detached loggers when(() => client.detachedLogger(any())).thenAnswer((invocation) { final name = invocation.positionalArguments.first; @@ -4981,1212 +4978,487 @@ void main() { ).thenAnswer((_) async {}); }); - group( - '${EventType.messageNew} or ${EventType.notificationMessageNew}', - () { - final initialLastMessageAt = DateTime.now(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - lastMessageAt: initialLastMessageAt, - ); - - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - Event createNewMessageEvent(Message message) { - return Event( - cid: channel.cid, - type: EventType.messageNew, - message: message, - ); - } - - test( - "should update 'channel.lastMessageAt'", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); - - test( - "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Message is visible to the current user. - restrictedVisibility: [client.state.currentUser!.id], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Older than the current 'channel.lastMessageAt'. - createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when Message is shadowed", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - shadowed: true, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); - - test( - "should not update 'channel.lastMessageAt' when Message is ephemeral", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - type: MessageType.ephemeral, - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + group('Non-initialized channel state validation', () { + test( + 'should throw StateError when accessing cooldown on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(() => channel.cooldown, throwsA(isA())); + }, + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test( + 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should throw StateError when accessing cooldownStream on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); + }); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); + group('Initialized channel cooldown functionality', () { + late Channel channel; - test( - "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Message is only visible to user-1 not the current user. - restrictedVisibility: const ['user-1'], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + tearDown(() => channel.dispose()); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should return default cooldown value of 0 for initialized channel', + () => expect(channel.cooldown, equals(0)), + ); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, + test('should return custom cooldown value when set in channel model', () { + final channelWithCooldown = ChannelModel( + id: channelId, + type: channelType, + cooldown: 30, ); - test( - "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + final stateWithCooldown = ChannelState(channel: channelWithCooldown); + final testChannel = Channel.fromState(client, stateWithCooldown); + addTearDown(testChannel.dispose); - when( - () => channel.config?.skipLastMsgUpdateForSystemMsgs, - ).thenReturn(true); + expect(testChannel.cooldown, equals(30)); + }); - final message = Message( - type: MessageType.system, - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test('should return 0 remaining cooldown when no cooldown is set', () { + expect(channel.getRemainingCooldown(), equals(0)); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test('should return cooldown stream with default value', () { + expectLater(channel.cooldownStream.take(1), emits(0)); + }); + }); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + group('Thread reply cooldown', () { + const currentUserId = 'test-user-id'; // matches FakeClientState default + const cooldownDuration = 30; // seconds - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, + Channel _buildChannelWithCooldown() { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + cooldown: cooldownDuration, + ownCapabilities: [ChannelCapability.slowMode], ); + final state = ChannelState(channel: channelModel); + final ch = Channel.fromState(client, state); + // isUpToDate is seeded true by default + return ch; + } - test("should update 'unreadCount'", () async { - expect(channel.state?.unreadCount, equals(0)); + test( + 'should return positive cooldown after current user sends a thread reply', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + // Simulate a thread reply by the current user sent just now. + final threadReply = Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), ); + ch.state!.updateThreadInfo('parent-msg-1', [threadReply]); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(ch.getRemainingCooldown(), greaterThan(0)); + }, + ); - expect(channel.state?.unreadCount, equals(1)); + test( + 'should return 0 cooldown when thread reply was sent outside the cooldown window', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final message2 = Message( - id: 'test-message-id-2', - user: User(id: 'other-user'), - createdAt: message.createdAt.add(const Duration(seconds: 3)), + // Reply sent cooldownDuration+5 seconds ago — outside the window. + final oldReply = Message( + id: 'thread-reply-old', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp().subtract( + const Duration(seconds: cooldownDuration + 5), + ), + user: User(id: currentUserId), ); + ch.state!.updateThreadInfo('parent-msg-1', [oldReply]); - final newMessage2Event = createNewMessageEvent(message2); - client.addEvent(newMessage2Event); - - // Wait for the event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(2)); - }); + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); - group("should not update 'unreadCount'", () { - test( - 'when the message is silent', - () async { - expect(channel.state?.unreadCount, equals(0)); + test( + 'should not trigger cooldown for a thread reply from another user', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final message = Message( - id: 'test-message-id', - silent: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + final otherUserReply = Message( + id: 'thread-reply-other', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: DateTime.timestamp(), + user: User(id: 'other-user-id'), + ); + ch.state!.updateThreadInfo('parent-msg-1', [otherUserReply]); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should clear cooldown when the most-recent own message is hard-deleted', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - expect(channel.state?.unreadCount, equals(0)); - }, + final ownMessage = Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), ); + ch.state!.updateMessage(ownMessage); + expect(ch.getRemainingCooldown(), greaterThan(0)); - test( - 'when the message is shadowed', - () async { - expect(channel.state?.unreadCount, equals(0)); + ch.state!.deleteMessage(ownMessage, hardDelete: true); + expect(ch.getRemainingCooldown(), equals(0)); + }, + ); - final message = Message( - id: 'test-message-id', - shadowed: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test( + 'currentUserLastMessageAtStream emits a new timestamp when own message is added', + () async { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + final emissions = []; + final sub = ch.currentUserLastMessageAtStream.listen(emissions.add); + addTearDown(sub.cancel); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Let the seed emission settle. + await Future.delayed(Duration.zero); + final seededLast = emissions.last; - expect(channel.state?.unreadCount, equals(0)); - }, + ch.state!.updateMessage( + Message( + id: 'msg-1', + createdAt: DateTime.timestamp(), + user: User(id: currentUserId), + ), ); + await Future.delayed(Duration.zero); - test( - 'when the message type is ephemeral', - () async { - expect(channel.state?.unreadCount, equals(0)); + expect(emissions.last, isNotNull); + expect(emissions.last, isNot(equals(seededLast))); + }, + ); - final message = Message( - id: 'test-message-id', - type: MessageType.ephemeral, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test( + 'getRemainingCooldown uses the explicit [lastMessageAt] override', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // No messages in state, so the default path returns 0. + expect(ch.getRemainingCooldown(), equals(0)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Override pointing inside the cooldown window → positive remaining. + final recent = DateTime.timestamp().subtract(const Duration(seconds: 5)); + expect(ch.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); - expect(channel.state?.unreadCount, equals(0)); - }, + // Override pointing outside the window → 0. + final old = DateTime.timestamp().subtract( + const Duration(seconds: cooldownDuration + 5), ); + expect(ch.getRemainingCooldown(lastMessageAt: old), equals(0)); + }, + ); - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); - - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test( + 'currentUserLastMessageAt picks the latest across channel messages and threads', + () { + final ch = _buildChannelWithCooldown(); + addTearDown(ch.dispose); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + final older = DateTime.timestamp().subtract(const Duration(seconds: 20)); + final newer = DateTime.timestamp().subtract(const Duration(seconds: 5)); - expect(channel.state?.unreadCount, equals(0)); - }, + // Older message in the main channel. + ch.state!.updateMessage( + Message( + id: 'msg-1', + createdAt: older, + user: User(id: currentUserId), + ), ); + // Newer reply in a thread. + ch.state!.updateThreadInfo('parent-msg-1', [ + Message( + id: 'thread-reply-1', + parentId: 'parent-msg-1', + showInChannel: false, + createdAt: newer, + user: User(id: currentUserId), + ), + ]); - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Should pick the newer thread reply, not the older channel message. + final result = ch.currentUserLastMessageAt; + expect(result, isNotNull); + expect(result!.isAtSameMomentAs(newer), isTrue); + }, + ); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + group('Disposed channel state validation', () { + late Channel channel; - // Wait for the event to get processed - await Future.delayed(Duration.zero); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + test( + 'should throw StateError when accessing cooldown after disposal', + () { + // First verify it works when initialized + expect(channel.cooldown, equals(0)); - test( - 'when the message is from the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Dispose the channel + channel.dispose(); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Now accessing cooldown should throw + expect(() => channel.cooldown, throwsA(isA())); + }, + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test( + 'should throw StateError when accessing getRemainingCooldown after disposal', + () { + // First verify it works when initialized + expect(channel.getRemainingCooldown(), equals(0)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Dispose the channel + channel.dispose(); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + // Now accessing getRemainingCooldown should throw + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); - test( - 'when the message is not restricted for the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + test( + 'should throw StateError when accessing cooldownStream after disposal', + () { + // First verify it works when initialized + expectLater(channel.cooldownStream.take(1), emits(0)); - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - restrictedVisibility: const ['other-user-2'], - ); + // Dispose the channel + channel.dispose(); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Now accessing cooldownStream should throw + expect(() => channel.cooldownStream, throwsA(isA())); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test( + 'should handle race condition scenario - initialization then quick disposal', + () { + // This test simulates the race condition that was causing the production crash + final channelState = _generateChannelState(channelId, channelType); + final raceChannel = Channel.fromState(client, channelState); - expect(channel.state?.unreadCount, equals(0)); - }, - ); - }); + // Verify it works initially + expect(raceChannel.cooldown, equals(0)); - test( - 'should submit channel for delivery when message is received', - () async { - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Simulate quick disposal (like what happens with rapid navigation) + raceChannel.dispose(); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // This should throw StateError instead of crashing with null check operator + expect(() => raceChannel.cooldown, throwsA(isA())); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + expect(raceChannel.getRemainingCooldown, throwsA(isA())); + }, + ); + }); - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); - }, - ); + group('Channel message count events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - test( - 'should not duplicate when server echoes back an optimistically ' - 'inserted message with a later createdAt', - () async { - // Local message used as the input to `channel.sendMessage`. - final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 3)); - final localMessage = Message( - id: 'test-message-id', - text: 'Hello world!', - user: client.state.currentUser, - createdAt: localCreatedAt, - ); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - // Mock the network send to return the message unchanged so the - // optimistic insert + sent-state update both land on the same - // `createdAt`. The bug fires later, on the WS echo. - final sendMessageResponse = SendMessageResponse() - ..message = localMessage.copyWith(state: MessageState.sent); - when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + tearDown(() { + channel.dispose(); + }); - await channel.sendMessage(localMessage); + test( + 'should update channel messageCount when event contains channelMessageCount', + () async { + // Verify initial state - no messageCount + expect(channel.messageCount, isNull); - expect(channel.state!.messages, hasLength(1)); + // Create event with channelMessageCount + final messageCountEvent = Event( + cid: channel.cid, + type: EventType.messageNew, + channelMessageCount: 42, + ); - // Server then broadcasts the same message via a `message.new` - // event with a slightly later `createdAt` (server-assigned - // timestamp). - final serverMessage = localMessage.copyWith( - createdAt: localCreatedAt.add(const Duration(milliseconds: 50)), - ); - client.addEvent(createNewMessageEvent(serverMessage)); + // Dispatch event + client.addEvent(messageCountEvent); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // The state should contain exactly one message with that id, - // not a duplicate. - final matching = channel.state!.messages.where((it) => it.id == localMessage.id); - expect(matching, hasLength(1)); - expect(channel.state!.messages, hasLength(1)); - }, - ); + // Verify channel messageCount was updated + expect(channel.messageCount, equals(42)); + }, + ); - test( - 'should not duplicate when the locally-sent message is no longer ' - 'the latest (retry-after-offline scenario)', - () async { - // Mirrors the offline-retry flow: a local message is sent, then - // another message arrives via WS while the local one is still - // pending. When the retry finally succeeds the server response's - // `createdAt` is later than the intervening message, so the - // locally-sent copy is no longer `messages.last`. - final localCreatedAt = initialLastMessageAt.add(const Duration(seconds: 1)); - final localMessage = Message( - id: 'local-message-id', + test( + 'should update channel messageCount from message.new and message.deleted events', + () async { + // Test with message.new event - count increases + final messageNewEvent = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'new-message-1', text: 'Hello world!', - user: client.state.currentUser, - createdAt: localCreatedAt, - ); - - final sendMessageResponse = SendMessageResponse() - ..message = localMessage.copyWith(state: MessageState.sent); - when(() => client.sendMessage(any(), channelId, channelType)).thenAnswer((_) async => sendMessageResponse); + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); - await channel.sendMessage(localMessage); + client.addEvent(messageNewEvent); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(1)); - // Another message arrives via WS with a later `createdAt`, - // pushing the locally-sent message off the tail. - final otherMessage = Message( - id: 'other-message-id', - user: User(id: 'other-user'), - createdAt: localCreatedAt.add(const Duration(seconds: 2)), - ); - client.addEvent(createNewMessageEvent(otherMessage)); - await Future.delayed(Duration.zero); + // Test with another message.new event - count increases + final messageNewEvent2 = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'new-message-2', + text: 'Second message', + user: User(id: 'user-2'), + ), + channelMessageCount: 2, + ); - // Server then broadcasts the locally-sent message via - // `message.new` with a `createdAt` that is later than the - // intervening message — exactly the shape produced by a - // successful retry after another message arrived in between. - final serverEcho = localMessage.copyWith( - createdAt: otherMessage.createdAt.add(const Duration(seconds: 1)), - ); - client.addEvent(createNewMessageEvent(serverEcho)); - await Future.delayed(Duration.zero); + client.addEvent(messageNewEvent2); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(2)); - final localMatches = channel.state!.messages.where((it) => it.id == localMessage.id); - expect(localMatches, hasLength(1)); - expect(channel.state!.messages, hasLength(2)); - }, - ); - }, - ); + // Test with message.deleted event - count decreases + final messageDeletedEvent = Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: Message( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); - group( - EventType.messageUpdated, - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + client.addEvent(messageDeletedEvent); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(1)); + }, + ); - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], + test( + 'should preserve other channel properties when updating messageCount', + () async { + // Set initial channel state with some properties + final initialChannel = channel.state?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + memberCount: 5, + frozen: true, ); - channel = Channel.fromState(client, channelState); - }); + if (initialChannel != null) { + channel.state?.updateChannelState( + channel.state!.channelState.copyWith(channel: initialChannel), + ); + } - tearDown(() => channel.dispose()); + // Verify initial state + expect(channel.name, 'Test Channel'); + expect(channel.memberCount, equals(5)); + expect(channel.frozen, equals(true)); + expect(channel.messageCount, isNull); - Event createUpdateMessageEvent(Message message) { - return Event( + // Update messageCount via event + final messageCountEvent = Event( cid: channel.cid, - type: EventType.messageUpdated, - message: message, + type: EventType.messageNew, + channelMessageCount: 100, ); - } - - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", - () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - final newMessageEvent = createUpdateMessageEvent(message); - client.addEvent(newMessageEvent); + client.addEvent(messageCountEvent); + await Future.delayed(Duration.zero); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Verify messageCount was updated while preserving other properties + expect(channel.messageCount, equals(100)); + expect(channel.name, 'Test Channel'); + expect(channel.memberCount, equals(5)); + expect(channel.frozen, equals(true)); + }, + ); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - }, - ); + test( + 'should provide messageCountStream for reactive updates', + () async { + final emitted = []; + final subscription = channel.messageCountStream.listen(emitted.add); + addTearDown(subscription.cancel); + await Future.delayed(Duration.zero); - test( - 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', - () async { - const messageId = 'test-message-id'; - const oldText = 'Old text'; - const newText = 'New text'; - final message = Message( - id: messageId, - user: client.state.currentUser, - text: oldText, - pinned: true, + // Update messageCount multiple times, repeating one of the counts. + final counts = [1, 5, 5, 10]; + for (final (index, count) in counts.indexed) { + final event = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'msg-$index', + text: 'Message $count', + user: User(id: 'user-1'), + ), + channelMessageCount: count, ); - final firstUpdateEvent = createUpdateMessageEvent(message); - client.addEvent(firstUpdateEvent); - - // Wait for the first event to get processed + client.addEvent(event); await Future.delayed(Duration.zero); + } - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(oldText)); - - final updatedMessage = message.copyWith(text: newText); - final secondUpdateEvent = createUpdateMessageEvent(updatedMessage); - client.addEvent(secondUpdateEvent); - - // Wait for the second event to get processed - await Future.delayed(Duration.zero); + // The repeated count should not be emitted twice. + expect(emitted, equals([null, 1, 5, 10])); + }, + ); + }); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(newText)); - }, - ); - - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages " - 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', - () async { - const firstMessageId = 'first-test-message-id'; - const secondMessageId = 'second-test-message-id'; - final firstMessage = Message( - id: firstMessageId, - user: client.state.currentUser, - pinned: true, - ); - final secondMessage = firstMessage.copyWith(id: secondMessageId); - - final firstUpdateEvent = createUpdateMessageEvent(firstMessage); - client.addEvent(firstUpdateEvent); - - // Wait for the first event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); - - final secondUpdateEvent = createUpdateMessageEvent(secondMessage); - client.addEvent(secondUpdateEvent); - - // Wait for the second event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(2)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); - expect( - channel.state?.pinnedMessages[1].id, - equals(secondMessageId), - ); - }, - ); - - test( - "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", - () async { - const messageId = 'test-message-id'; - final pinnedMessage = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - - final pinEvent = createUpdateMessageEvent(pinnedMessage); - client.addEvent(pinEvent); - - // Wait for the pin event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - - final unpinnedMessage = pinnedMessage.copyWith(pinned: false); - final unpinEvent = createUpdateMessageEvent(unpinnedMessage); - client.addEvent(unpinEvent); - - // Wait for the unpin event to get processed - await Future.delayed(Duration.zero); - - expect(channel.state?.pinnedMessages, isEmpty); - }, - ); - - // A `message.updated` event for a message outside the loaded window - // would otherwise upsert into the sorted list — creating a phantom - // entry with a gap. The guard is "id not in the loaded list", and - // is independent of `isUpToDate` — even at the latest page we may - // have paginated past older history and receive an event for a - // message no longer in memory. - group('when message is outside the loaded window', () { - test( - 'should NOT insert unknown message into `messages` list', - () async { - // Simulate "we have the latest page but not older history": - // seed the tail messages. - final tail = List.generate( - 3, - (i) => Message( - id: 'tail-$i', - user: client.state.currentUser, - text: 'tail $i', - createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), - ), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: tail), - ); - expect(channel.state!.messages, hasLength(3)); - - // Event for a message on an older page we don't have loaded. - final olderPageEdit = Message( - id: 'older-page-msg', - user: client.state.currentUser, - text: 'edited on older page', - createdAt: DateTime.utc(2025, 1, 1), - ); - client.addEvent(createUpdateMessageEvent(olderPageEdit)); - await Future.delayed(Duration.zero); - - // Tail is unchanged, no phantom entry inserted at position 0. - expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - - test( - 'should update message in place when it IS in the loaded window', - () async { - const messageId = 'known'; - final seeded = Message( - id: messageId, - user: client.state.currentUser, - text: 'old', - createdAt: DateTime.utc(2026), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [seeded]), - ); - channel.state!.isUpToDate = false; - - final edited = seeded.copyWith(text: 'new'); - client.addEvent(createUpdateMessageEvent(edited)); - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); - expect(stored.text, equals('new')); - }, - ); - - test( - 'should still add to pinnedMessages when pinned:true even if not in loaded window', - () async { - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - - const messageId = 'pin-me'; - final pinned = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); - client.addEvent(createUpdateMessageEvent(pinned)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages.length, equals(1)); - expect(channel.state!.pinnedMessages.first.id, equals(messageId)); - }, - ); - - test( - 'should NOT insert unknown reply into threads[parentId]', - () async { - const parentId = 'parent-1'; - final knownReply = Message( - id: 'known-reply', - parentId: parentId, - user: client.state.currentUser, - createdAt: DateTime.utc(2026), - ); - // Populate threads[parentId] via addNewMessage's thread-only path. - channel.state!.addNewMessage(knownReply); - await Future.delayed(Duration.zero); - expect(channel.state!.threads[parentId], hasLength(1)); - - channel.state!.isUpToDate = false; - - final phantomReply = Message( - id: 'other-reply', - parentId: parentId, - user: client.state.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - client.addEvent(createUpdateMessageEvent(phantomReply)); - await Future.delayed(Duration.zero); - - expect(channel.state!.threads[parentId]!.map((m) => m.id), ['known-reply']); - }, - ); - - test( - 'should NOT create phantom threads[parentId] entry for unloaded thread', - () async { - const parentId = 'unloaded-parent'; - // The thread was never paged in, so there's no entry for it. - expect(channel.state!.threads.containsKey(parentId), isFalse); - - channel.state!.isUpToDate = false; - - final phantomReply = Message( - id: 'phantom-reply', - parentId: parentId, - user: client.state.currentUser, - text: 'edited', - createdAt: DateTime.utc(2026, 1, 2), - ); - client.addEvent(createUpdateMessageEvent(phantomReply)); - await Future.delayed(Duration.zero); - - // The dropped reply must not leave behind an empty thread entry. - expect(channel.state!.threads.containsKey(parentId), isFalse); - }, - ); - - test( - 'should still expire activeLiveLocations for out-of-window message', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'loc-msg', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - // Seed only activeLiveLocations, keeping `messages` empty — - // the exact "message is outside the loaded window" scenario. - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - activeLiveLocations: [liveLocation], - ), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, hasLength(1)); - - // A message.updated that expires the live location. - final expiredMessage = Message( - id: 'loc-msg', - text: 'Live location shared', - sharedLocation: liveLocation.copyWith( - endAt: DateTime.now().subtract(const Duration(minutes: 1)), - ), - ); - client.addEvent(createUpdateMessageEvent(expiredMessage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, isEmpty); - }, - ); - }); - }, - ); - - // A reply with `show_in_channel = true` is mirrored into both `messages` - // and `threads[parentId]`. When the thread isn't loaded (fresh hydration, - // user never opened the thread) the channel-level copy is the only place - // locally-cached fields like `ownReactions`/`poll` survive — so reaction - // and message-update events for such replies must still find it. - group( - 'reply events with `show_in_channel = true` and unloaded thread', - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const replyId = 'mirrored-reply-id'; - const parentId = 'parent-message-id'; - // Pinned createdAt keeps oldIndex lookups stable in `updateMessage`. - final createdAt = DateTime.utc(2026, 1, 1); - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - // Seeds a single reply into the channel-level `messages` while leaving - // `threads[parentId]` empty — the exact regression scenario. - Message seedMirroredReply({ - List ownReactions = const [], - Poll? poll, - }) { - final reply = Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - ownReactions: ownReactions, - poll: poll, - pollId: poll?.id, - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [reply]), - ); - return reply; - } - - test( - '`reaction.new` from another user preserves `ownReactions`', - () async { - final ownReaction = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - seedMirroredReply(ownReactions: [ownReaction]); - // Pre-condition: thread is not loaded. - expect(channel.state!.threads, isEmpty); - - // Server reaction events don't echo back the recipient's own - // reactions, so the listener must pull them from the cached copy. - final otherUserReaction = Reaction( - type: 'love', - messageId: replyId, - user: User(id: 'other-user'), - ); - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionNew, - reaction: otherUserReaction, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - latestReactions: [otherUserReaction], - ), - ), - ); - - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [ownReaction]); - }, - ); - - test( - '`reaction.deleted` strips only the removed reaction', - () async { - final kept = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - final removed = Reaction( - type: 'love', - messageId: replyId, - user: client.state.currentUser, - ); - seedMirroredReply(ownReactions: [kept, removed]); - expect(channel.state!.threads, isEmpty); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.reactionDeleted, - reaction: removed, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - ), - ), - ); - - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [kept]); - }, - ); - - test( - '`message.updated` preserves `poll`, `pollId`, and `ownReactions`', - () async { - final ownReaction = Reaction( - type: 'like', - messageId: replyId, - user: client.state.currentUser, - ); - // Partial server updates can omit poll/pollId/ownReactions; the - // cached copy is what backfills them. - final poll = Poll( - id: 'poll-1', - name: 'Pick one', - options: const [ - PollOption(text: 'A'), - PollOption(text: 'B'), - ], - ); - seedMirroredReply(ownReactions: [ownReaction], poll: poll); - expect(channel.state!.threads, isEmpty); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: Message( - id: replyId, - parentId: parentId, - showInChannel: true, - user: client.state.currentUser, - createdAt: createdAt, - text: 'edited', - ), - ), - ); - - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.firstWhere((it) => it.id == replyId); - expect(stored.ownReactions, [ownReaction]); - expect(stored.poll?.id, poll.id); - expect(stored.pollId, poll.id); - }, - ); - }, - ); - - // A `message.deleted` event for a message outside the loaded window - // must not upsert a "deleted" record into the sorted list — that would - // create a phantom entry with a gap. Pinned + live-location - // side-effects must still fire. - group( - EventType.messageDeleted, - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - Event createDeleteMessageEvent(Message message, {bool hardDelete = false}) { - return Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: message.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), - ), - hardDelete: hardDelete, - ); - } - - // Same design as the `messageUpdated` guards: the check is - // "message-in-loaded-window" and is independent of `isUpToDate` — - // an event for a message on an older, unloaded page must not be - // turned into a phantom "deleted" record inserted into the sorted - // list. - group('when message is outside the loaded window', () { - test( - 'soft delete does NOT insert phantom "deleted" record into messages', - () async { - final tail = List.generate( - 3, - (i) => Message( - id: 'tail-$i', - user: client.state.currentUser, - text: 'tail $i', - createdAt: DateTime.utc(2026, 6, 1).add(Duration(seconds: i)), - ), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: tail), - ); - expect(channel.state!.messages, hasLength(3)); - - final olderPage = Message( - id: 'older-page-msg', - user: client.state.currentUser, - text: 'gone', - createdAt: DateTime.utc(2025, 1, 1), - ); - client.addEvent(createDeleteMessageEvent(olderPage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages.map((m) => m.id), ['tail-0', 'tail-1', 'tail-2']); - }, - ); - - test( - 'soft delete marks message as deleted when it IS in the loaded window', - () async { - const messageId = 'known'; - final seeded = Message( - id: messageId, - user: client.state.currentUser, - text: 'hi', - createdAt: DateTime.utc(2026), - ); - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(messages: [seeded]), - ); - channel.state!.isUpToDate = false; - - client.addEvent(createDeleteMessageEvent(seeded)); - await Future.delayed(Duration.zero); - - final stored = channel.state!.messages.singleWhere((m) => m.id == messageId); - expect(stored.type, equals(MessageType.deleted)); - expect(stored.deletedAt, isNotNull); - }, - ); - - test( - 'soft delete unpins a pinned-but-not-in-window message via _pinIsValid', - () async { - const messageId = 'pinned-msg'; - final pinned = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - createdAt: DateTime.utc(2026), - ); - // Seed only the pinnedMessages list — message absent from - // the main `messages` window. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(pinnedMessages: [pinned]), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, hasLength(1)); - - client.addEvent(createDeleteMessageEvent(pinned)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - - test( - 'soft delete still clears activeLiveLocations even when message not in window', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'loc-msg', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - // Seed only activeLiveLocations, keeping `messages` empty. - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - activeLiveLocations: [liveLocation], - ), - ); - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, hasLength(1)); - - final locationMessage = Message( - id: 'loc-msg', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - client.addEvent(createDeleteMessageEvent(locationMessage)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.activeLiveLocations, isEmpty); - }, - ); - - test( - 'hard delete is a no-op when message is not in the loaded window', - () async { - channel.state!.isUpToDate = false; - expect(channel.state!.messages, isEmpty); - - final phantom = Message( - id: 'phantom', - user: client.state.currentUser, - text: 'gone', - createdAt: DateTime.utc(2026), - ); - client.addEvent(createDeleteMessageEvent(phantom, hardDelete: true)); - await Future.delayed(Duration.zero); - - expect(channel.state!.messages, isEmpty); - expect(channel.state!.pinnedMessages, isEmpty); - }, - ); - }); - }, - ); - - group('Member Events', () { + group('Channel member count events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; late Channel channel; @@ -6201,5447 +5473,888 @@ void main() { }); test( - 'should update membership when member is updated and is current user', + 'should update channel memberCount when event contains channelMemberCount', () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); - final now = DateTime.now(); - - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); - - // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.channelRole, isNull); - expect(channel.membership?.isModerator, false); - expect(channel.isPinned, isFalse); - expect(channel.isArchived, isFalse); - - // Create updated member with same userId but updated properties - final updatedMember = currentMember.copyWith( - channelRole: 'moderator', - isModerator: true, - pinnedAt: now, - archivedAt: now, - ); + // Verify initial state - default memberCount + expect(channel.memberCount, equals(0)); - // Create member updated event - final memberUpdatedEvent = Event( + // Create event with channelMemberCount + final memberCountEvent = Event( cid: channel.cid, - type: EventType.memberUpdated, - user: currentUser, - member: updatedMember, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 42, ); // Dispatch event - client.addEvent(memberUpdatedEvent); + client.addEvent(memberCountEvent); // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.userId, equals(currentUser?.id)); - expect(channel.membership?.channelRole, equals('moderator')); - expect(channel.membership?.isModerator, isTrue); - expect(channel.isPinned, isTrue); - expect(channel.isArchived, isTrue); + // Verify channel memberCount was updated + expect(channel.memberCount, equals(42)); }, ); test( - 'should update membership user when any event containing user is updated', + 'should update channel memberCount from member.added and member.removed events', () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); - - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); - - // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(currentUser?.id)); - expect(channel.membership?.user?.role, equals(currentUser?.role)); - - // Create updated user with same userId but updated properties - final updatedUser = currentUser?.copyWith(role: 'moderator'); - - // Create any event with same updated user as membership. - final anyEvent = Event( + // Test with member.added event - count increases + final memberAddedEvent = Event( cid: channel.cid, - type: EventType.any, - user: updatedUser, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 1, ); - // Dispatch event - client.addEvent(anyEvent); - - // Wait for the event to be processed + client.addEvent(memberAddedEvent); await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(1)); + expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-1'])); - // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(updatedUser?.id)); - expect(channel.membership?.user?.role, equals(updatedUser?.role)); - }, - ); - }); - - group('Watching Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - test( - '${EventType.userWatchingStart} adds the watcher and updates watcherCount', - () async { - final watcher = User(id: 'watcher-1'); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: watcher, - watcherCount: 3, + // Test with another member.added event - count increases + final memberAddedEvent2 = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-2', + user: User(id: 'user-2'), ), + channelMemberCount: 2, ); - // Wait for the event to get processed + client.addEvent(memberAddedEvent2); await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 3); + expect(channel.memberCount, equals(2)); expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), + channel.state?.channelState.members?.map((it) => it.userId), + equals(['user-1', 'user-2']), ); - }, - ); - - test( - '${EventType.userWatchingStop} removes the watcher and updates watcherCount', - () async { - final watcher = User(id: 'watcher-1'); - // The watcher starts watching first (count = 2). - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: watcher, - watcherCount: 2, - ), - ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 2); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-1'), + // Test with member.removed event - count decreases + final memberRemovedEvent = Event( + cid: channel.cid, + type: EventType.memberRemoved, + user: User(id: 'user-1'), + channelMemberCount: 1, ); - // Then stops watching (count = 1). - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStop, - user: watcher, - watcherCount: 1, - ), - ); + client.addEvent(memberRemovedEvent); await Future.delayed(Duration.zero); - - expect(channel.state!.watcherCount, 1); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - isNot(contains('watcher-1')), - ); + expect(channel.memberCount, equals(1)); + expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-2'])); }, ); test( - 'watching event without watcherCount preserves the existing count', + 'should preserve other channel properties when updating memberCount', () async { - // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), + // Set initial channel state with some properties + final initialChannel = channel.state?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + messageCount: 7, + frozen: true, ); - expect(channel.state!.watcherCount, 5); - // A watching event that omits watcher_count must not wipe the count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.userWatchingStart, - user: User(id: 'watcher-2'), + if (initialChannel != null) { + channel.state?.updateChannelState( + channel.state!.channelState.copyWith(channel: initialChannel), + ); + } + + // Verify initial state + expect(channel.name, 'Test Channel'); + expect(channel.messageCount, equals(7)); + expect(channel.frozen, equals(true)); + expect(channel.memberCount, equals(0)); + + // Update memberCount via event + final memberCountEvent = Event( + cid: channel.cid, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), ), + channelMemberCount: 100, ); + + client.addEvent(memberCountEvent); await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 5); - expect( - channel.state!.channelState.watchers?.map((it) => it.id), - contains('watcher-2'), - ); + // Verify memberCount was updated while preserving other properties + expect(channel.memberCount, equals(100)); + expect(channel.name, 'Test Channel'); + expect(channel.messageCount, equals(7)); + expect(channel.frozen, equals(true)); }, ); test( - '${EventType.messageNew} updates watcherCount from the event', + 'should not update memberCount when the event omits channelMemberCount', () async { - expect(channel.state!.watcherCount, isNull); - - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: DateTime.now(), - ); - + // Seed a known member count. client.addEvent( Event( cid: channel.cid, - type: EventType.messageNew, - message: message, - watcherCount: 7, + type: EventType.memberAdded, + member: Member( + userId: 'user-1', + user: User(id: 'user-1'), + ), + channelMemberCount: 5, ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 7); - }, - ); - - test( - '${EventType.messageNew} without watcherCount preserves the existing count', - () async { - // Seed an initial watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 4), - ); - expect(channel.state!.watcherCount, 4); + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(5)); - // A local/optimistic message.new without watcher_count must not - // reset the count. + // An event without the field should leave the count untouched. client.addEvent( Event( cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'test-message-id-2', - user: client.state.currentUser, - createdAt: DateTime.now(), + type: EventType.memberAdded, + member: Member( + userId: 'user-2', + user: User(id: 'user-2'), ), ), ); - await Future.delayed(Duration.zero); - expect(channel.state!.watcherCount, 4); + await Future.delayed(Duration.zero); + expect(channel.memberCount, equals(5)); }, ); test( - '${EventType.notificationMessageNew} does not overwrite watcherCount', + 'should provide memberCountStream for reactive updates', () async { - // Seed a known watcher count. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith(watcherCount: 5), - ); - expect(channel.state!.watcherCount, 5); + final emitted = []; + final subscription = channel.memberCountStream.listen(emitted.add); + addTearDown(subscription.cancel); + await Future.delayed(Duration.zero); - // notification.message_new is delivered to non-watchers and reports - // watcher_count: 0; it must not clobber the real count. - client.addEvent( - Event( + // Update memberCount multiple times, repeating one of the counts. + final counts = [1, 5, 5, 10]; + for (final (index, count) in counts.indexed) { + final event = Event( cid: channel.cid, - type: EventType.notificationMessageNew, - message: Message( - id: 'notif-message-id', - user: User(id: 'other-user'), - createdAt: DateTime.now(), + type: EventType.memberAdded, + member: Member( + userId: 'user-$index', + user: User(id: 'user-$index'), ), - watcherCount: 0, - ), - ); - await Future.delayed(Duration.zero); + channelMemberCount: count, + ); - expect(channel.state!.watcherCount, 5); + client.addEvent(event); + await Future.delayed(Duration.zero); + } + + // The repeated count should not be emitted twice. + expect(emitted, equals([0, 1, 5, 10])); }, ); }); + }); - group('Read Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ); - - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should update read state on message read event', () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create message read event - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); + group('Channel filterTags', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - // Verify read state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); }); - test( - 'should add a new read state if not exist on message read event', - () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create mark read notification event - final markReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(markReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); - }, + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - test( - 'should not update channel read state on thread message read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'channel-msg-1', - ); - - // Setup initial channel read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, 'channel-msg-1'); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create a thread-scoped message.read event (thread != null) - final threadMessageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'thread-reply-99', - thread: Thread( - channelCid: channel.cid!, - parentMessageId: 'parent-msg-1', - createdByUserId: currentUser.id, - replyCount: 3, - participantCount: 2, - ), - ); - - // Dispatch event - client.addEvent(threadMessageReadEvent); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); - // Channel read state must be untouched — thread reads - // must not clobber the channel-level Read. - final after = channel.state?.read.first; - expect(after?.unreadMessages, 10); - expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - }, + test('should return filterTags from channel state', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], ); - test('should update read state on notification mark unread event', () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - - // Create mark unread notification event - final markUnreadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkUnread, - user: currentUser, - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); - - // Dispatch event - client.addEvent(markUnreadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 15); - expect(updatedRead?.lastReadMessageId, 'message-100'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2019)), isTrue); - }); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - test( - 'should add a new read state if not exist on notification mark unread', - () async { - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); + }); - // Create event for non-existing user - final markUnreadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkUnread, - user: User(id: 'non-existing-user'), - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); + test('should update filterTags when channel state is updated', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], + ); - // Dispatch event - client.addEvent(markUnreadEvent); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - // Wait for event to be processed - await Future.delayed(Duration.zero); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); - }, + final updatedChannel = channelModel.copyWith( + filterTags: ['tag3', 'tag4', 'tag5'], ); - test( - 'should preserve delivery info on message read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastDeliveredAt: DateTime(2021), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Setup initial read state with delivery info - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); + testChannel.state?.updateChannelState( + testChannel.state!.channelState.copyWith(channel: updatedChannel), + ); - // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastDeliveredAt, isNotNull); - expect( - read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); + expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); + }); + }); - // Create message read event (doesn't include delivery info) - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); + group('Typing Indicator', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); - // Dispatch event - client.addEvent(messageReadEvent); + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); - // Verify read state is updated but delivery info is preserved - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - // Delivery info should be preserved - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - test( - 'should reconcile delivery when message read event is from current user', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Create message read event from current user - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(messageReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - - test( - 'should reset unread count on notification mark read event', - () async { - final currentUser = client.state.currentUser!; - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Verify initial state - expect(channel.state?.unreadCount, 10); - - // notification.mark_read is delivered on the reading user's own - // connection, so it reaches non-watched channels as well. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'message-123', - ), - ); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, currentUser.id); - expect(channel.state?.unreadCount, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - }, - ); - - test( - 'should preserve delivery info on notification mark read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastDeliveredAt: DateTime(2021), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'message-123', - ), - ); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read state is updated but delivery info is preserved - final updatedRead = channel.state?.read.first; - expect(updatedRead?.unreadMessages, 0); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, - ); - - test( - 'should not update channel read state on thread notification mark ' - 'read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'channel-msg-1', - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'thread-reply-99', - thread: Thread( - channelCid: channel.cid!, - parentMessageId: 'parent-msg-1', - createdByUserId: currentUser.id, - replyCount: 3, - participantCount: 2, - ), - ), - ); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Channel read state must be untouched — thread reads - // must not clobber the channel-level Read. - final after = channel.state?.read.first; - expect(after?.unreadMessages, 10); - expect(after?.lastReadMessageId, 'channel-msg-1'); - expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - }, - ); - - test( - 'should reconcile delivery when notification mark read event is from ' - 'current user', - () async { - final currentUser = client.state.currentUser; - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - user: currentUser, - createdAt: DateTime(2022), - lastReadMessageId: 'message-123', - ), - ); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - - test('should update read state on message delivered event', () async { - final currentUser = User(id: 'test-user'); - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - final currentRead = Read( - user: currentUser, - lastRead: distantPast, - unreadMessages: 5, - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state has no delivery info - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.lastDeliveredAt, isNull); - expect(read?.lastDeliveredMessageId, isNull); - - // Create message delivered event - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify delivery state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'message-456'); - }); - - test( - 'should add a new read state if not exist on message delivered event', - () async { - final newUser = User(id: 'new-user'); - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create message delivered event for new user - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: newUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-789', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read state was created with delivery info - final updated = channel.state?.read; - expect(updated?.length, 1); - final newRead = updated?.first; - expect(newRead?.user.id, 'new-user'); - expect(newRead?.lastDeliveredAt, isNotNull); - expect( - newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(newRead?.lastDeliveredMessageId, 'message-789'); - // lastRead should default to distantPast - expect( - newRead?.lastRead.isAtSameMomentAs(distantPast), - isTrue, - ); - }, - ); - - test( - 'should preserve read info on message delivered event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'read-msg-123', - ); - - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); - - // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, 'read-msg-123'); - - // Create message delivered event (doesn't include read info) - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'delivered-msg-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify delivery state is updated but read info is preserved - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - // Read info should be preserved - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), - isTrue, - ); - expect(updatedRead?.unreadMessages, 10); - expect(updatedRead?.lastReadMessageId, 'read-msg-123'); - }, - ); - - test( - 'should reconcile delivery when message delivered event is from current user', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); - - // Create message delivered event from current user - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', - ); - - // Dispatch event - client.addEvent(messageDeliveredEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); - }); - - group('Draft events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle draft.updated event for channel drafts', () async { - // Verify initial state - expect(channel.state?.draft, isNull); - - // Create Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); - }); - - test('should handle draft.updated event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a regular message - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - ), - ); - - // Verify initial state - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - - // Create thread Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - }); - - test('should handle draft.deleted event for channel drafts', () async { - // Setup initial state with a draft - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ), - ), - ); - - // Verify initial state - final draft = channel.state?.draft; - expect(draft, isNotNull); - expect(draft?.message.text, 'test message'); - - // Create draft.deleted event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: draft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel draft was updated - expect(channel.state?.draft, isNull); - }); - - test('should handle draft.deleted event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a thread draft - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ), - ), - ); - - // Verify initial state - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); - - // Create draft.deleted event - final draftDeletedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: threadDraft, - ); - - // Dispatch event - client.addEvent(draftDeletedEvent); - - // Allow event to be processed - await Future.delayed(Duration.zero); - - // Verify thread draft was removed - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - }); - - test( - 'should update current channel draft if draft.updated event is emitted', - () async { - // Setup initial state with a draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: initialDraft, - ), - ); - - // Verify initial state - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); - - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated message'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'updated message'); - }, - ); - - test( - 'should update current thread draft if draft.updated event is emitted', - () async { - const threadParentMessageId = 'thread-parent-id'; - - // Setup initial state with a thread draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ); - - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: initialDraft, - ), - ); - - // Verify initial state - final draft = channel.state?.threadDraft(threadParentMessageId); - expect(draft, isNotNull); - expect(draft?.message.text, 'thread reply'); - - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated thread reply'), - ); - - // Create draft.updated event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, - ); - - // Dispatch event - client.addEvent(draftUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'updated thread reply'); - }, - ); - }); - - group('Reminder events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle reminder.created event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message without reminder - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - ); - - channel.state?.updateMessage(message); - - // Verify initial state - no reminder - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // Create reminder - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: DateTime.now().add(const Duration(days: 30)), - ); - - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); - - // Dispatch event - client.addEvent(reminderCreatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was added - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); - - test('should handle reminder.updated event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); - - channel.state?.updateMessage(message); - - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); - - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); - - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); - - // Dispatch event - client.addEvent(reminderUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was updated - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); - - test('should handle reminder.deleted event', () async { - const messageId = 'test-message-id'; - - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); - - channel.state?.updateMessage(message); - - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); - - // Dispatch event - client.addEvent(reminderDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify message reminder was removed - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); - - test('should handle reminder.created event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message without reminder - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - // `Message.createdAt` falls back to `DateTime.now()` per call when - // not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), - ); - - channel.state?.updateMessage(threadMessage); - - // Verify initial state - no reminder - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); - - // Create reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); - - // Dispatch event - client.addEvent(reminderCreatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread message reminder was added - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); - - test('should handle reminder.updated event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - // `Message.createdAt` falls back to `DateTime.now()` per call when - // not provided, which breaks merge/sort keyed on createdAt. - createdAt: DateTime.now(), - ); - - channel.state?.updateMessage(threadMessage); - - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); - - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); - - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); - - // Dispatch event - client.addEvent(reminderUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread message reminder was updated - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); - - test('should handle reminder.deleted event for thread messages', () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); - - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - // Explicit `createdAt` so `Message.createdAt` is deterministic - // across reads — without one it falls back to `DateTime.now()` - // on every call, which breaks any sort/merge keyed on createdAt. - createdAt: DateTime.now(), - ); - - channel.state?.updateMessage(threadMessage); - - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); - - // Dispatch event - client.addEvent(reminderDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread message reminder was removed - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); - }); - - group('Location events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle location.shared event', () async { - // Verify initial state - expect(channel.state?.activeLiveLocations, isEmpty); - - // Create live location - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: locationMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was added - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message, isNotNull); - - // Check if active live location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg1')); - }); - - test('should handle location.updated event', () async { - // Setup initial state with location message - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - channel.state?.addNewMessage(locationMessage); - - // Create updated location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, // Updated latitude - longitude: -74.1000, // Updated longitude - ); - - final updatedMessage = locationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Create location.updated event - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedMessage, - ); - - // Dispatch event - client.addEvent(locationUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was updated - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.latitude, equals(40.7500)); - expect(message?.sharedLocation?.longitude, equals(-74.1000)); - - // Check if active live location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - expect(activeLiveLocations?.first.longitude, equals(-74.1000)); - }); - - test('should handle location.expired event', () async { - // Setup initial state with location message - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial message - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create expired location - final expiredLocation = liveLocation.copyWith( - endAt: DateTime.now().subtract(const Duration(hours: 1)), - ); - - final expiredMessage = locationMessage.copyWith( - sharedLocation: expiredLocation, - ); - - // Create location.expired event - final locationExpiredEvent = Event( - cid: channel.cid, - type: EventType.locationExpired, - message: expiredMessage, - ); - - // Dispatch event - client.addEvent(locationExpiredEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was updated - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation?.isExpired, isTrue); - - // Check if active live location was removed - expect(channel.state?.activeLiveLocations, isEmpty); - }); - - test('should not add static location to active locations', () async { - final staticLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - // No endAt - static location - ); - - final staticMessage = Message( - id: 'msg1', - text: 'Static location shared', - sharedLocation: staticLocation, - ); - - // Create location.shared event - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: staticMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if message was added - final messages = channel.state?.messages; - final message = messages?.firstWhere((m) => m.id == 'msg1'); - expect(message?.sharedLocation, isNotNull); - - // Check if active live location was NOT updated (should remain empty) - expect(channel.state?.activeLiveLocations, isEmpty); - }); - - test( - 'should update active locations when location message is deleted', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Verify initial state - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - final messageDeletedEvent = Event( - type: EventType.messageDeleted, - cid: channel.cid, - message: locationMessage.copyWith( - type: MessageType.deleted, - deletedAt: DateTime.timestamp(), - ), - ); - - // Dispatch event - client.addEvent(messageDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify active locations are updated - expect(channel.state?.activeLiveLocations, isEmpty); - }, - ); - - test('should merge locations with same key', () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add initial location for setup - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create new location with same user, channel, and device - final newLocation = Location( - channelCid: channel.cid, - userId: 'user1', // Same user - messageId: 'msg2', // Different message - latitude: 40.7500, - longitude: -74.1000, - createdByDeviceId: 'device1', // Same device - endAt: DateTime.now().add(const Duration(hours: 2)), - ); - - final newMessage = Message( - id: 'msg2', - text: 'Updated location', - sharedLocation: newLocation, - ); - - // Create location.shared event for the new message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: newMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Should still have only one active location (merged) - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('msg2')); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - }); - - test( - 'should handle multiple active locations from different devices', - () async { - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final locationMessage = Message( - id: 'msg1', - text: 'Live location shared', - sharedLocation: liveLocation, - ); - - // Add first location for setup - channel.state?.addNewMessage(locationMessage); - expect(channel.state?.activeLiveLocations, hasLength(1)); - - // Create location from different device - final location2 = Location( - channelCid: channel.cid, - userId: 'user1', // Same user - messageId: 'msg2', - latitude: 34.0522, - longitude: -118.2437, - createdByDeviceId: 'device2', // Different device - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final message2 = Message( - id: 'msg2', - text: 'Location from device 2', - sharedLocation: location2, - ); - - // Create location.shared event for the second message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: message2, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Should have two active locations - expect(channel.state?.activeLiveLocations, hasLength(2)); - }, - ); - - test('should handle location messages in threads', () async { - final parentMessage = Message( - id: 'parent1', - text: 'Thread parent', - ); - - // Add parent message first for setup - channel.state?.addNewMessage(parentMessage); - - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'thread-msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Create location.shared event for the thread message - final locationSharedEvent = Event( - cid: channel.cid, - type: EventType.locationShared, - message: threadLocationMessage, - ); - - // Dispatch event - client.addEvent(locationSharedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if thread message was added - final thread = channel.state?.threads['parent1']; - expect(thread, contains(threadLocationMessage)); - - // Check if location was added to active locations - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); - }); - - test('should update thread location messages', () async { - final parentMessage = Message( - id: 'parent1', - text: 'Thread parent', - ); - - final liveLocation = Location( - channelCid: channel.cid, - userId: 'user1', - messageId: 'thread-msg1', - latitude: 40.7128, - longitude: -74.0060, - createdByDeviceId: 'device1', - endAt: DateTime.now().add(const Duration(hours: 1)), - ); - - final threadLocationMessage = Message( - id: 'thread-msg1', - text: 'Live location in thread', - parentId: 'parent1', - sharedLocation: liveLocation, - ); - - // Add messages - channel.state?.addNewMessage(parentMessage); - channel.state?.addNewMessage(threadLocationMessage); - - // Update the location - final updatedLocation = liveLocation.copyWith( - latitude: 40.7500, - longitude: -74.1000, - ); - - final updatedThreadMessage = threadLocationMessage.copyWith( - sharedLocation: updatedLocation, - ); - - // Create location.updated event for the thread message - final locationUpdatedEvent = Event( - cid: channel.cid, - type: EventType.locationUpdated, - message: updatedThreadMessage, - ); - - // Dispatch event - client.addEvent(locationUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Check if thread message was updated - final thread = channel.state?.threads['parent1']; - final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); - expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); - expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); - - // Check if active location was updated - final activeLiveLocations = channel.state?.activeLiveLocations; - expect(activeLiveLocations, hasLength(1)); - expect(activeLiveLocations?.first.latitude, equals(40.7500)); - expect(activeLiveLocations?.first.longitude, equals(-74.1000)); - }); - }); - - group('Channel push preference events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test('should handle channel.push_preference.updated event', () async { - // Verify initial state - expect(channel.state?.channelState.pushPreferences, isNull); - - // Create channel push preference - final channelPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.mentions, - disabledUntil: DateTime.now().add(const Duration(hours: 1)), - ); - - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: channelPushPreference, - ); - - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences, isNotNull); - expect(updatedPreferences?.chatLevel, ChatLevel.mentions); - expect( - updatedPreferences?.disabledUntil, - channelPushPreference.disabledUntil, - ); - }); - - test('should update existing channel push preferences', () async { - // Set initial push preferences - const initialPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.all, - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - pushPreferences: initialPushPreference, - ), - ); - - // Verify initial state - final pushPreferences = channel.state?.channelState.pushPreferences; - expect(pushPreferences?.chatLevel, ChatLevel.all); - expect(pushPreferences?.disabledUntil, isNull); - - // Create updated channel push preference - final updatedPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.none, - disabledUntil: DateTime.now().add(const Duration(hours: 2)), - ); - - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: updatedPushPreference, - ); - - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences?.chatLevel, ChatLevel.none); - expect( - updatedPreferences?.disabledUntil, - updatedPushPreference.disabledUntil, - ); - }); - }); - - group('User messages deleted event', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - late MockPersistenceClient persistenceClient; - - setUp(() { - persistenceClient = MockPersistenceClient(); - when(() => client.chatPersistenceClient).thenReturn(persistenceClient); - when( - () => persistenceClient.deleteMessagesFromUser( - cid: any(named: 'cid'), - userId: any(named: 'userId'), - hardDelete: any(named: 'hardDelete'), - deletedAt: any(named: 'deletedAt'), - ), - ).thenAnswer((_) async {}); - when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); - when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => >{}); - - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( - 'should soft delete all messages from user when hardDelete is false', - () async { - // Setup: Add messages from different users - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - expect( - channel.state?.messages.where((m) => m.user?.id == 'user-1').length, - equals(2), - ); - expect( - channel.state?.messages.where((m) => m.user?.id == 'user-2').length, - equals(1), - ); - - // Create user.messages.deleted event (soft delete) - final deletedAt = DateTime.now(); - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - createdAt: deletedAt, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify user1's messages are soft deleted - expect(channel.state?.messages.length, equals(3)); - final deletedMessages = channel.state?.messages.where((m) => m.user?.id == 'user-1').toList(); - expect(deletedMessages?.length, equals(2)); - for (final message in deletedMessages!) { - expect(message.type, equals(MessageType.deleted)); - expect(message.deletedAt, isNotNull); - expect(message.state.isDeleted, isTrue); - } - - // Verify user2's message is unaffected - final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(user2Message?.type, isNot(MessageType.deleted)); - expect(user2Message?.deletedAt, isNull); - }, - ); - - test( - 'should hard delete all messages from user when hardDelete is true', - () async { - // Setup: Add messages from different users - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify user1's messages are removed - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - // Verify user2's message still exists - final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); - expect(user2Message, isNotNull); - expect(user2Message?.user?.id, equals('user-2')); - }, - ); - - test( - 'should handle thread messages from user', - () async { - // Setup: Add parent and thread messages - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final parentMessage = Message( - id: 'parent-msg', - text: 'Parent message', - user: user2, - ); - final threadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - final threadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 1', - user: user1, - parentId: 'parent-msg', - ); - - channel.state?.addNewMessage(parentMessage); - channel.state?.addNewMessage(threadMessage1); - channel.state?.addNewMessage(threadMessage2); - - // Verify initial state - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.threads['parent-msg']?.length, equals(2)); - - // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify thread messages are soft deleted - final threadMessages = channel.state?.threads['parent-msg']; - expect(threadMessages?.length, equals(2)); - for (final message in threadMessages!) { - expect(message.type, equals(MessageType.deleted)); - expect(message.state.isDeleted, isTrue); - } - - // Verify parent message is unaffected - final parent = channel.state?.messages.first; - expect(parent?.type, isNot(MessageType.deleted)); - }, - ); - - test( - 'should do nothing when user is null', - () async { - // Setup: Add messages - final user1 = User(id: 'user-1', name: 'User 1'); - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - channel.state?.addNewMessage(message1); - - // Verify initial state - expect(channel.state?.messages.length, equals(1)); - - // Create user.messages.deleted event without user - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - hardDelete: false, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify messages are unaffected - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.first.type, - isNot(MessageType.deleted), - ); - }, - ); - - test( - 'should handle empty message list', - () async { - // Setup: Empty channel - expect(channel.state?.messages.length, equals(0)); - - // Create user.messages.deleted event - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: User(id: 'user-1'), - hardDelete: false, - ); - - // Dispatch event - should not throw - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify state is still empty - expect(channel.state?.messages.length, equals(0)); - }, - ); - - test( - 'should delete messages from persistence when hardDelete is true', - () async { - // Setup: Add messages from different users - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - final message2 = Message( - id: 'msg-2', - text: 'Another message from user 1', - user: user1, - ); - final message3 = Message( - id: 'msg-3', - text: 'Message from user 2', - user: user2, - ); - - channel.state?.addNewMessage(message1); - channel.state?.addNewMessage(message2); - channel.state?.addNewMessage(message3); - - // Verify initial state - expect(channel.state?.messages.length, equals(3)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify messages are removed from persistence - verify( - () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), - ).called(1); - verify( - () => persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), - ).called(1); - - // Verify user1's messages are removed from state - expect(channel.state?.messages.length, equals(1)); - expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - }, - ); - - test( - 'should not delete from persistence when hardDelete is false', - () async { - // Setup: Add messages - final user1 = User(id: 'user-1', name: 'User 1'); - final message1 = Message( - id: 'msg-1', - text: 'Message from user 1', - user: user1, - ); - - channel.state?.addNewMessage(message1); - - // Create user.messages.deleted event (soft delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify persistence deletion methods were NOT called - verifyNever(() => persistenceClient.deleteMessageByIds(any())); - verifyNever(() => persistenceClient.deletePinnedMessageByIds(any())); - - // Verify message is soft deleted (still in state) - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.messages.first.type, equals(MessageType.deleted)); - }, - ); - - test( - 'should delete all user messages including those only in storage', - () async { - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final stateMessage1 = Message( - id: 'msg-1', - text: 'Message from user 1 in state', - user: user1, - pinned: true, - ); - final stateMessage2 = Message( - id: 'msg-2', - text: 'Message from user 2 in state', - user: user2, - ); - final stateThreadMessage1 = Message( - id: 'thread-msg-1', - text: 'Thread message from user 1 in state', - user: user1, - parentId: 'msg-1', - ); - final stateThreadMessage2 = Message( - id: 'thread-msg-2', - text: 'Another thread message from user 2 in state', - user: user2, - parentId: 'msg-1', - ); - - // Load the state with only 2 messages and 1 thread with 2 replies. - // Note: In reality, storage may contain many more user1 messages - // (e.g., older messages not loaded into state yet), but the delete - // operation should remove ALL of them from storage. - channel.state?.addNewMessage(stateMessage1); - channel.state?.addNewMessage(stateMessage2); - channel.state?.addNewMessage(stateThreadMessage1); - channel.state?.addNewMessage(stateThreadMessage2); - - // Verify initial state has only 2 messages and 1 thread with 2 replies - expect(channel.state?.messages.length, equals(2)); - expect(channel.state?.threads['msg-1']?.length, equals(2)); - - // Create user.messages.deleted event (hard delete) - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: true, - ); - - // Dispatch event - client.addEvent(userMessagesDeletedEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify user1's messages are removed from state - expect(channel.state?.messages.length, equals(1)); - expect(channel.state?.threads['msg-1']?.length, equals(1)); - - expect( - channel.state?.messages.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - expect( - channel.state?.threads['msg-1']?.any((m) => m.user?.id == 'user-1'), - isFalse, - ); - - // Verify persistence delete was called - this handles ALL messages - // in storage (both those in state AND those only in storage) - verify( - () => persistenceClient.deleteMessagesFromUser( - cid: channel.cid, - userId: user1.id, - hardDelete: true, - deletedAt: any(named: 'deletedAt'), - ), - ).called(1); - - // Verify in-state messages were also removed from state's persistence - final capturedIds = - verify( - () => persistenceClient.deleteMessageByIds(captureAny()), - ).captured.first - as List; - - expect( - capturedIds, - containsAll([ - 'msg-1', // state message - 'thread-msg-1', // state thread message - ]), - ); - }, - ); - - test( - 'should delete every authored message across threads without ' - 'cross-thread leakage (regression: _updateThreadMessages)', - () async { - // user-1 authors a top-level message AND replies in two different - // threads (owned by user-2). The user.messages.deleted flow - // collects everything from user-1 across channel + threads and - // routes it through a single _updateMessages batch — historically - // this batch was passed unfiltered to every affected thread's - // merge, so replies to thread A leaked into thread B and v.v. - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final parentA = Message(id: 'parent-A', text: 'Thread A', user: user2); - final parentB = Message(id: 'parent-B', text: 'Thread B', user: user2); - - final topLevelFromUser1 = Message( - id: 'top-1', - text: 'user-1 top-level message', - user: user1, - ); - final replyA = Message( - id: 'reply-A', - text: 'user-1 reply in thread A', - user: user1, - parentId: 'parent-A', - ); - final replyB = Message( - id: 'reply-B', - text: 'user-1 reply in thread B', - user: user1, - parentId: 'parent-B', - ); - - channel.state?.addNewMessage(parentA); - channel.state?.addNewMessage(parentB); - channel.state?.addNewMessage(topLevelFromUser1); - channel.state?.addNewMessage(replyA); - channel.state?.addNewMessage(replyB); - - // Initial state: each thread has exactly its own reply. - expect( - channel.state?.threads['parent-A']?.map((m) => m.id), - equals(['reply-A']), - ); - expect( - channel.state?.threads['parent-B']?.map((m) => m.id), - equals(['reply-B']), - ); - - // Trigger the multi-thread batch via user.messages.deleted. - final userMessagesDeletedEvent = Event( - cid: channel.cid, - type: EventType.userMessagesDeleted, - user: user1, - hardDelete: false, - ); - client.addEvent(userMessagesDeletedEvent); - await Future.delayed(Duration.zero); - - // 1) Thread membership is preserved — no cross-thread leakage. - // Without the fix, replyB would leak into thread A and v.v. - expect( - channel.state?.threads['parent-A']?.map((m) => m.id), - equals(['reply-A']), - reason: 'thread A must not contain replies from thread B', - ); - expect( - channel.state?.threads['parent-B']?.map((m) => m.id), - equals(['reply-B']), - reason: 'thread B must not contain replies from thread A', - ); - - // 2) Every message authored by user-1 is soft-deleted — top-level - // AND in both threads. The fix must not narrow this scope. - expect( - channel.state?.messages.firstWhere((m) => m.id == 'top-1').type, - equals(MessageType.deleted), - reason: 'top-level user-1 message must be deleted', - ); - expect( - channel.state?.threads['parent-A']?.first.type, - equals(MessageType.deleted), - reason: 'thread A reply from user-1 must be deleted', - ); - expect( - channel.state?.threads['parent-B']?.first.type, - equals(MessageType.deleted), - reason: 'thread B reply from user-1 must be deleted', - ); - - // 3) Other users' messages are unaffected. - expect( - channel.state?.messages.firstWhere((m) => m.id == 'parent-A').type, - isNot(MessageType.deleted), - ); - expect( - channel.state?.messages.firstWhere((m) => m.id == 'parent-B').type, - isNot(MessageType.deleted), - ); - }, - ); - }); - }); - - group('ChannelReadHelper', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - // A date in the distant past (Unix epoch), useful for representing old dates - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test('userReadOf should return read for specific user', () { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - - final reads = [ - Read(user: user1, lastRead: now), - Read(user: user2, lastRead: now.add(const Duration(minutes: 1))), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final user1Read = channel.state!.userReadOf(userId: 'user-1'); - expect(user1Read, isNotNull); - expect(user1Read!.user.id, 'user-1'); - expect(user1Read.lastRead, now); - - final user2Read = channel.state!.userReadOf(userId: 'user-2'); - expect(user2Read, isNotNull); - expect(user2Read!.user.id, 'user-2'); - - final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); - expect(nonExistentRead, isNull); - }); - - test('userReadOf should return null when userId is null', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final read = channel.state!.userReadOf(userId: null); - expect(read, isNull); - }); - - test( - 'userReadStreamOf should emit read updates for specific user', - () async { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); - - expectLater( - readStream, - emitsInOrder([ - isNull, // initial state - isA().having((r) => r.user.id, 'userId', 'user-1'), - ]), - ); - - // Update with read - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [Read(user: user1, lastRead: now)], - ), - ); - }, - ); - - test('readsOf should return reads that have marked message as read', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final reads = [ - // user1 has read the message - Read(user: user1, lastRead: now.add(const Duration(seconds: 1))), - // user2 has not read the message yet - Read(user: user2, lastRead: distantPast), - // user3 has read the message - Read(user: user3, lastRead: now.add(const Duration(seconds: 2))), - // sender should be excluded - Read(user: sender, lastRead: now.add(const Duration(seconds: 10))), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final messageReads = channel.state!.readsOf(message: message); - expect(messageReads.length, 2); - expect(messageReads.map((r) => r.user.id), containsAll(['user-1', 'user-3'])); - expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); - expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); - }); - - test('readsOfStream should emit read updates for a message', () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final readsStream = channel.state!.readsOfStream(message: message); - - expectLater( - readsStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding read - ]), - ); - - // Update with read - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [Read(user: user1, lastRead: now.add(const Duration(seconds: 1)))], - ), - ); - }); - - test('deliveriesOf should return reads that have delivered the message', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); - final user4 = User(id: 'user-4', name: 'User 4'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final reads = [ - // user1 has delivered the message - Read( - user: user1, - lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), - ), - // user2 has not delivered the message yet (lastDeliveredAt is before message) - Read( - user: user2, - lastRead: distantPast, - lastDeliveredAt: distantPast, - ), - // user3 has no lastDeliveredAt - Read( - user: user3, - lastRead: distantPast, - ), - // user4 has read the message (implicitly delivered) - Read( - user: user4, - lastRead: now.add(const Duration(seconds: 1)), - ), - // sender should be excluded - Read( - user: sender, - lastRead: now.add(const Duration(seconds: 10)), - lastDeliveredAt: now.add(const Duration(seconds: 10)), - ), - ]; - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); - - final deliveries = channel.state!.deliveriesOf(message: message); - expect(deliveries.length, 2); - expect(deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); - }); - - test('deliveriesOfStream should emit delivery updates for a message', () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); - - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final deliveriesStream = channel.state!.deliveriesOfStream(message: message); - - expectLater( - deliveriesStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding delivery - ]), - ); - - // Update with delivery - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [ - Read( - user: user1, - lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), - ), - ], - ), - ); - }); - }); - - group('ChannelCapabilityCheck', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - /// Parameterized test for channel capability extension properties - void testCapability( - String capabilityName, - ChannelCapability capability, - bool Function(Channel) getterMethod, - ) { - test('can$capabilityName returns false when capability is absent', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), false); - }); - - test('can$capabilityName returns true when capability is present', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [capability], - ); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), true); - }); - } - - // Test all channel capabilities using the parameterized function - testCapability( - 'SendMessage', - ChannelCapability.sendMessage, - (channel) => channel.canSendMessage, - ); - - testCapability( - 'SendReply', - ChannelCapability.sendReply, - (channel) => channel.canSendReply, - ); - - testCapability( - 'SendRestrictedVisibilityMessage', - ChannelCapability.sendRestrictedVisibilityMessage, - (channel) => channel.canSendRestrictedVisibilityMessage, - ); - - testCapability( - 'SendReaction', - ChannelCapability.sendReaction, - (channel) => channel.canSendReaction, - ); - - testCapability( - 'SendLinks', - ChannelCapability.sendLinks, - (channel) => channel.canSendLinks, - ); - - testCapability( - 'CreateAttachment', - ChannelCapability.createAttachment, - (channel) => channel.canCreateAttachment, - ); - - testCapability( - 'FreezeChannel', - ChannelCapability.freezeChannel, - (channel) => channel.canFreezeChannel, - ); - - testCapability( - 'SetChannelCooldown', - ChannelCapability.setChannelCooldown, - (channel) => channel.canSetChannelCooldown, - ); - - testCapability( - 'LeaveChannel', - ChannelCapability.leaveChannel, - (channel) => channel.canLeaveChannel, - ); - - testCapability( - 'JoinChannel', - ChannelCapability.joinChannel, - (channel) => channel.canJoinChannel, - ); - - testCapability( - 'PinMessage', - ChannelCapability.pinMessage, - (channel) => channel.canPinMessage, - ); - - testCapability( - 'DeleteAnyMessage', - ChannelCapability.deleteAnyMessage, - (channel) => channel.canDeleteAnyMessage, - ); - - testCapability( - 'DeleteOwnMessage', - ChannelCapability.deleteOwnMessage, - (channel) => channel.canDeleteOwnMessage, - ); - - testCapability( - 'UpdateAnyMessage', - ChannelCapability.updateAnyMessage, - (channel) => channel.canUpdateAnyMessage, - ); - - testCapability( - 'UpdateOwnMessage', - ChannelCapability.updateOwnMessage, - (channel) => channel.canUpdateOwnMessage, - ); - - testCapability( - 'SearchMessages', - ChannelCapability.searchMessages, - (channel) => channel.canSearchMessages, - ); - - testCapability( - 'SendTypingEvents', - ChannelCapability.sendTypingEvents, - (channel) => channel.canSendTypingEvents, - ); - - testCapability( - 'UploadFile', - ChannelCapability.uploadFile, - (channel) => channel.canUploadFile, - ); - - testCapability( - 'DeleteChannel', - ChannelCapability.deleteChannel, - (channel) => channel.canDeleteChannel, - ); - - testCapability( - 'UpdateChannel', - ChannelCapability.updateChannel, - (channel) => channel.canUpdateChannel, - ); - - testCapability( - 'UpdateChannelMembers', - ChannelCapability.updateChannelMembers, - (channel) => channel.canUpdateChannelMembers, - ); - - testCapability( - 'UpdateThread', - ChannelCapability.updateThread, - (channel) => channel.canUpdateThread, - ); - - testCapability( - 'QuoteMessage', - ChannelCapability.quoteMessage, - (channel) => channel.canQuoteMessage, - ); - - testCapability( - 'BanChannelMembers', - ChannelCapability.banChannelMembers, - (channel) => channel.canBanChannelMembers, - ); - - testCapability( - 'FlagMessage', - ChannelCapability.flagMessage, - (channel) => channel.canFlagMessage, - ); - - testCapability( - 'MuteChannel', - ChannelCapability.muteChannel, - (channel) => channel.canMuteChannel, - ); - - testCapability( - 'SendCustomEvents', - ChannelCapability.sendCustomEvents, - (channel) => channel.canSendCustomEvents, - ); - - testCapability( - 'ReceiveReadEvents', - ChannelCapability.readEvents, - (channel) => channel.canReceiveReadEvents, - ); - - testCapability( - 'ReceiveConnectEvents', - ChannelCapability.connectEvents, - (channel) => channel.canReceiveConnectEvents, - ); - - testCapability( - 'UseTypingEvents', - ChannelCapability.typingEvents, - (channel) => channel.canUseTypingEvents, - ); - - testCapability( - 'InSlowMode', - ChannelCapability.slowMode, - (channel) => channel.isInSlowMode, - ); - - testCapability( - 'SkipSlowMode', - ChannelCapability.skipSlowMode, - (channel) => channel.canSkipSlowMode, - ); - - testCapability( - 'SendPoll', - ChannelCapability.sendPoll, - (channel) => channel.canSendPoll, - ); - - testCapability( - 'CastPollVote', - ChannelCapability.castPollVote, - (channel) => channel.canCastPollVote, - ); - - testCapability( - 'QueryPollVotes', - ChannelCapability.queryPollVotes, - (channel) => channel.canQueryPollVotes, - ); - - testCapability( - 'ShareLocation', - ChannelCapability.shareLocation, - (channel) => channel.canShareLocation, - ); - - testCapability( - 'NotifyChannel', - ChannelCapability.notifyChannel, - (channel) => channel.canNotifyChannel, - ); - - testCapability( - 'NotifyHere', - ChannelCapability.notifyHere, - (channel) => channel.canNotifyHere, - ); - - testCapability( - 'NotifyRole', - ChannelCapability.notifyRole, - (channel) => channel.canNotifyRole, - ); - - testCapability( - 'NotifyGroup', - ChannelCapability.notifyGroup, - (channel) => channel.canNotifyGroup, - ); - - test('returns correct values with multiple capabilities', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ - ChannelCapability.sendMessage, - ChannelCapability.sendReply, - ChannelCapability.deleteOwnMessage, - ], - ); - - final channel = Channel.fromState(client, channelState); - expect(channel.canSendMessage, true); - expect(channel.canSendReply, true); - expect(channel.canDeleteOwnMessage, true); - expect(channel.canDeleteAnyMessage, false); - expect(channel.canUpdateChannel, false); - }); - }); - - group('Channel State Validation and Cooldown', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); - - group('Non-initialized channel state validation', () { - test( - 'should throw StateError when accessing cooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing cooldownStream on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - }); - - group('Initialized channel cooldown functionality', () { - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() => channel.dispose()); - - test( - 'should return default cooldown value of 0 for initialized channel', - () => expect(channel.cooldown, equals(0)), - ); - - test('should return custom cooldown value when set in channel model', () { - final channelWithCooldown = ChannelModel( - id: channelId, - type: channelType, - cooldown: 30, - ); - - final stateWithCooldown = ChannelState(channel: channelWithCooldown); - final testChannel = Channel.fromState(client, stateWithCooldown); - addTearDown(testChannel.dispose); - - expect(testChannel.cooldown, equals(30)); - }); - - test('should return 0 remaining cooldown when no cooldown is set', () { - expect(channel.getRemainingCooldown(), equals(0)); - }); - - test('should return cooldown stream with default value', () { - expectLater(channel.cooldownStream.take(1), emits(0)); - }); - }); - - group('Thread reply cooldown', () { - const currentUserId = 'test-user-id'; // matches FakeClientState default - const cooldownDuration = 30; // seconds - - Channel _buildChannelWithCooldown() { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - cooldown: cooldownDuration, - ownCapabilities: [ChannelCapability.slowMode], - ); - final state = ChannelState(channel: channelModel); - final ch = Channel.fromState(client, state); - // isUpToDate is seeded true by default - return ch; - } - - test( - 'should return positive cooldown after current user sends a thread reply', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // Simulate a thread reply by the current user sent just now. - final threadReply = Message( - id: 'thread-reply-1', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ); - ch.state!.updateThreadInfo('parent-msg-1', [threadReply]); - - expect(ch.getRemainingCooldown(), greaterThan(0)); - }, - ); - - test( - 'should return 0 cooldown when thread reply was sent outside the cooldown window', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // Reply sent cooldownDuration+5 seconds ago — outside the window. - final oldReply = Message( - id: 'thread-reply-old', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp().subtract( - const Duration(seconds: cooldownDuration + 5), - ), - user: User(id: currentUserId), - ); - ch.state!.updateThreadInfo('parent-msg-1', [oldReply]); - - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'should not trigger cooldown for a thread reply from another user', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final otherUserReply = Message( - id: 'thread-reply-other', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: DateTime.timestamp(), - user: User(id: 'other-user-id'), - ); - ch.state!.updateThreadInfo('parent-msg-1', [otherUserReply]); - - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'should clear cooldown when the most-recent own message is hard-deleted', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final ownMessage = Message( - id: 'msg-1', - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ); - ch.state!.updateMessage(ownMessage); - expect(ch.getRemainingCooldown(), greaterThan(0)); - - ch.state!.deleteMessage(ownMessage, hardDelete: true); - expect(ch.getRemainingCooldown(), equals(0)); - }, - ); - - test( - 'currentUserLastMessageAtStream emits a new timestamp when own message is added', - () async { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final emissions = []; - final sub = ch.currentUserLastMessageAtStream.listen(emissions.add); - addTearDown(sub.cancel); - - // Let the seed emission settle. - await Future.delayed(Duration.zero); - final seededLast = emissions.last; - - ch.state!.updateMessage( - Message( - id: 'msg-1', - createdAt: DateTime.timestamp(), - user: User(id: currentUserId), - ), - ); - await Future.delayed(Duration.zero); - - expect(emissions.last, isNotNull); - expect(emissions.last, isNot(equals(seededLast))); - }, - ); - - test( - 'getRemainingCooldown uses the explicit [lastMessageAt] override', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - // No messages in state, so the default path returns 0. - expect(ch.getRemainingCooldown(), equals(0)); - - // Override pointing inside the cooldown window → positive remaining. - final recent = DateTime.timestamp().subtract(const Duration(seconds: 5)); - expect(ch.getRemainingCooldown(lastMessageAt: recent), greaterThan(0)); - - // Override pointing outside the window → 0. - final old = DateTime.timestamp().subtract( - const Duration(seconds: cooldownDuration + 5), - ); - expect(ch.getRemainingCooldown(lastMessageAt: old), equals(0)); - }, - ); - - test( - 'currentUserLastMessageAt picks the latest across channel messages and threads', - () { - final ch = _buildChannelWithCooldown(); - addTearDown(ch.dispose); - - final older = DateTime.timestamp().subtract(const Duration(seconds: 20)); - final newer = DateTime.timestamp().subtract(const Duration(seconds: 5)); - - // Older message in the main channel. - ch.state!.updateMessage( - Message( - id: 'msg-1', - createdAt: older, - user: User(id: currentUserId), - ), - ); - // Newer reply in a thread. - ch.state!.updateThreadInfo('parent-msg-1', [ - Message( - id: 'thread-reply-1', - parentId: 'parent-msg-1', - showInChannel: false, - createdAt: newer, - user: User(id: currentUserId), - ), - ]); - - // Should pick the newer thread reply, not the older channel message. - final result = ch.currentUserLastMessageAt; - expect(result, isNotNull); - expect(result!.isAtSameMomentAs(newer), isTrue); - }, - ); - }); - - group('Disposed channel state validation', () { - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - test( - 'should throw StateError when accessing cooldown after disposal', - () { - // First verify it works when initialized - expect(channel.cooldown, equals(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldown should throw - expect(() => channel.cooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing getRemainingCooldown after disposal', - () { - // First verify it works when initialized - expect(channel.getRemainingCooldown(), equals(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing getRemainingCooldown should throw - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); - - test( - 'should throw StateError when accessing cooldownStream after disposal', - () { - // First verify it works when initialized - expectLater(channel.cooldownStream.take(1), emits(0)); - - // Dispose the channel - channel.dispose(); - - // Now accessing cooldownStream should throw - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - - test( - 'should handle race condition scenario - initialization then quick disposal', - () { - // This test simulates the race condition that was causing the production crash - final channelState = _generateChannelState(channelId, channelType); - final raceChannel = Channel.fromState(client, channelState); - - // Verify it works initially - expect(raceChannel.cooldown, equals(0)); - - // Simulate quick disposal (like what happens with rapid navigation) - raceChannel.dispose(); - - // This should throw StateError instead of crashing with null check operator - expect(() => raceChannel.cooldown, throwsA(isA())); - - expect(raceChannel.getRemainingCooldown, throwsA(isA())); - }, - ); - }); - - group('Channel message count events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( - 'should update channel messageCount when event contains channelMessageCount', - () async { - // Verify initial state - no messageCount - expect(channel.messageCount, isNull); - - // Create event with channelMessageCount - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 42, - ); - - // Dispatch event - client.addEvent(messageCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel messageCount was updated - expect(channel.messageCount, equals(42)); - }, - ); - - test( - 'should update channel messageCount from message.new and message.deleted events', - () async { - // Test with message.new event - count increases - final messageNewEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - client.addEvent(messageNewEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); - - // Test with another message.new event - count increases - final messageNewEvent2 = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-2', - text: 'Second message', - user: User(id: 'user-2'), - ), - channelMessageCount: 2, - ); - - client.addEvent(messageNewEvent2); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(2)); - - // Test with message.deleted event - count decreases - final messageDeletedEvent = Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); - - client.addEvent(messageDeletedEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); - }, - ); - - test( - 'should preserve other channel properties when updating messageCount', - () async { - // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - memberCount: 5, - frozen: true, - ); - - if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.channelState.copyWith(channel: initialChannel), - ); - } - - // Verify initial state - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); - expect(channel.messageCount, isNull); - - // Update messageCount via event - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 100, - ); - - client.addEvent(messageCountEvent); - await Future.delayed(Duration.zero); - - // Verify messageCount was updated while preserving other properties - expect(channel.messageCount, equals(100)); - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); - }, - ); - - test( - 'should provide messageCountStream for reactive updates', - () async { - final emitted = []; - final subscription = channel.messageCountStream.listen(emitted.add); - addTearDown(subscription.cancel); - await Future.delayed(Duration.zero); - - // Update messageCount multiple times, repeating one of the counts. - final counts = [1, 5, 5, 10]; - for (final (index, count) in counts.indexed) { - final event = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'msg-$index', - text: 'Message $count', - user: User(id: 'user-1'), - ), - channelMessageCount: count, - ); - - client.addEvent(event); - await Future.delayed(Duration.zero); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([null, 1, 5, 10])); - }, - ); - }); - - group('Channel member count events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - }); - - test( - 'should update channel memberCount when event contains channelMemberCount', - () async { - // Verify initial state - default memberCount - expect(channel.memberCount, equals(0)); - - // Create event with channelMemberCount - final memberCountEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 42, - ); - - // Dispatch event - client.addEvent(memberCountEvent); - - // Wait for the event to be processed - await Future.delayed(Duration.zero); - - // Verify channel memberCount was updated - expect(channel.memberCount, equals(42)); - }, - ); - - test( - 'should update channel memberCount from member.added and member.removed events', - () async { - // Test with member.added event - count increases - final memberAddedEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 1, - ); - - client.addEvent(memberAddedEvent); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(1)); - expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-1'])); - - // Test with another member.added event - count increases - final memberAddedEvent2 = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), - channelMemberCount: 2, - ); - - client.addEvent(memberAddedEvent2); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(2)); - expect( - channel.state?.channelState.members?.map((it) => it.userId), - equals(['user-1', 'user-2']), - ); - - // Test with member.removed event - count decreases - final memberRemovedEvent = Event( - cid: channel.cid, - type: EventType.memberRemoved, - user: User(id: 'user-1'), - channelMemberCount: 1, - ); - - client.addEvent(memberRemovedEvent); - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(1)); - expect(channel.state?.channelState.members?.map((it) => it.userId), equals(['user-2'])); - }, - ); - - test( - 'should preserve other channel properties when updating memberCount', - () async { - // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - messageCount: 7, - frozen: true, - ); - - if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.channelState.copyWith(channel: initialChannel), - ); - } - - // Verify initial state - expect(channel.name, 'Test Channel'); - expect(channel.messageCount, equals(7)); - expect(channel.frozen, equals(true)); - expect(channel.memberCount, equals(0)); - - // Update memberCount via event - final memberCountEvent = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 100, - ); - - client.addEvent(memberCountEvent); - await Future.delayed(Duration.zero); - - // Verify memberCount was updated while preserving other properties - expect(channel.memberCount, equals(100)); - expect(channel.name, 'Test Channel'); - expect(channel.messageCount, equals(7)); - expect(channel.frozen, equals(true)); - }, - ); - - test( - 'should not update memberCount when the event omits channelMemberCount', - () async { - // Seed a known member count. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-1', - user: User(id: 'user-1'), - ), - channelMemberCount: 5, - ), - ); - - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); - - // An event without the field should leave the count untouched. - client.addEvent( - Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-2', - user: User(id: 'user-2'), - ), - ), - ); - - await Future.delayed(Duration.zero); - expect(channel.memberCount, equals(5)); - }, - ); - - test( - 'should provide memberCountStream for reactive updates', - () async { - final emitted = []; - final subscription = channel.memberCountStream.listen(emitted.add); - addTearDown(subscription.cancel); - await Future.delayed(Duration.zero); - - // Update memberCount multiple times, repeating one of the counts. - final counts = [1, 5, 5, 10]; - for (final (index, count) in counts.indexed) { - final event = Event( - cid: channel.cid, - type: EventType.memberAdded, - member: Member( - userId: 'user-$index', - user: User(id: 'user-$index'), - ), - channelMemberCount: count, - ); - - client.addEvent(event); - await Future.delayed(Duration.zero); - } - - // The repeated count should not be emitted twice. - expect(emitted, equals([0, 1, 5, 10])); - }, - ); - }); - }); - - group('Channel filterTags', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test('should return filterTags from channel state', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); - - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); - - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - }); - - test('should update filterTags when channel state is updated', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); - - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); - - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - - final updatedChannel = channelModel.copyWith( - filterTags: ['tag3', 'tag4', 'tag5'], - ); - - testChannel.state?.updateChannelState( - testChannel.state!.channelState.copyWith(channel: updatedChannel), - ); - - expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); - }); - }); - - group('Typing Indicator', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); - - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test( - ".keystore should return if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(channel.keyStroke(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), - ), - ); - }, - ); - - test( - '.keystore should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(channel.keyStroke(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingEvent)), - ), - ); - }, - ); - - test( - ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final startTypingEvent = Event(type: EventType.typingStart); - final stopTypingEvent = Event(type: EventType.typingStop); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(startTypingEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopTypingEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.keyStroke(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(startTypingEvent)), - ), - ).called(1); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopTypingEvent)), - ), - ).called(1); - }, - ); - - test( - ".startTyping should return if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(channel.startTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ); - }, - ); - - test( - '.startTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - await expectLater(channel.startTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ); - }, - ); - - test(".startTyping should send 'typingStart' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStartEvent = Event(type: EventType.typingStart); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.startTyping(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ).called(1); - }); - - test(".stopTyping should return if we don't have the capability", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - await expectLater(channel.stopTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }); - - test( - '.stopTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), - ); - - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - await expectLater(channel.stopTyping(), completes); - - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }, - ); - - test(".stopTyping should send 'typingStop' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final typingStopEvent = Event(type: EventType.typingStop); - - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater(channel.stopTyping(), completes); - - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).called(1); - }); - }); - - group('Read Receipts', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); - - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); - - test( - ".markRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - completes, - ); - - verify( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).called(1); - }, - ); - - test( - ".markUnread should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markUnread('message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markUnread('message-id-123'), - completes, - ); - - verify( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).called(1); - }, - ); - - test( - ".markUnreadByTimestamp should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - await expectLater( - channel.markUnreadByTimestamp(timestamp), - throwsA(isA()), - ); - }, - ); - - test( - '.markUnreadByTimestamp should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - when( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markUnreadByTimestamp(timestamp), - completes, - ); - - verify( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).called(1); - }, - ); - - test( - ".markThreadRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markThreadRead('thread-id-123'), - throwsA(isA()), - ); - }, - ); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); test( - '.markThreadRead should succeed if we have the capability', + ".keystore should return if we don't have the capability", () async { final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [ChannelCapability.readEvents], + ownCapabilities: [], // no typingEvents capability ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - when( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); + final typingEvent = Event(type: EventType.typingStart); - await expectLater( - channel.markThreadRead('thread-id-123'), - completes, - ); + await expectLater(channel.keyStroke(), completes); - verify( - () => client.markThreadRead( + verifyNever( + () => client.sendEvent( channelId, channelType, - 'thread-id-123', + any(that: isSameEventAs(typingEvent)), ), - ).called(1); + ); }, ); test( - ".markThreadUnread should throw if we don't have the capability", + '.keystore should return when user privacy settings is disabled', () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), + ); + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [], // no readEvents capability + ownCapabilities: [ChannelCapability.typingEvents], ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - await expectLater( - channel.markThreadUnread('thread-id-123'), - throwsA(isA()), + final typingEvent = Event(type: EventType.typingStart); + + await expectLater(channel.keyStroke(), completes); + + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + ), ); }, ); test( - '.markThreadUnread should succeed if we have the capability', + ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", () async { final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [ChannelCapability.readEvents], + ownCapabilities: [ChannelCapability.typingEvents], ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); + final startTypingEvent = Event(type: EventType.typingStart); + final stopTypingEvent = Event(type: EventType.typingStop); + when( - () => client.markThreadUnread( + () => client.sendEvent( channelId, channelType, - 'thread-id-123', + any(that: isSameEventAs(startTypingEvent)), ), ).thenAnswer((_) async => EmptyResponse()); - await expectLater( - channel.markThreadUnread('thread-id-123'), - completes, - ); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater(channel.keyStroke(), completes); verify( - () => client.markThreadUnread( + () => client.sendEvent( channelId, channelType, - 'thread-id-123', + any(that: isSameEventAs(startTypingEvent)), ), ).called(1); - }, - ); - }); - - group('Local unread count', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - final currentUser = OwnUser(id: 'current-user-id'); - - late final client = MockStreamChatClient(); - - setUpAll(() { - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - when(() => client.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false, delayFactor: Duration.zero), - ); - when(() => client.state).thenReturn(FakeClientState(currentUser: currentUser)); - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - when( - () => client.channelDeliveryReporter.reconcileDelivery(any()), - ).thenAnswer((_) async {}); - client.isLocalUnreadCountEnabled = true; - }); - - // A "livestream-like" channel: read events are disabled, both via the - // channel-type config and the current user's own capabilities. - Channel _createLivestreamChannel({ - StreamChatClient? overrideClient, - List? messages, - List? reads, - }) { - final channelState = ChannelState( - channel: ChannelModel( - id: channelId, - type: channelType, - config: ChannelConfig(readEvents: false), - ownCapabilities: const [], // No readEvents capability. - ), - messages: messages, - read: reads, - ); - - final channel = Channel.fromState(overrideClient ?? client, channelState); - addTearDown(channel.dispose); - return channel; - } - test( - 'increments unreadCount locally for new messages when the channel has ' - 'no read events capability', - () async { - final channel = _createLivestreamChannel(); - expect(channel.state?.unreadCount, equals(0)); - - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - - client.addEvent( - Event(cid: channel.cid, type: EventType.messageNew, message: message), - ); - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(1)); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + ).called(1); }, ); test( - 'does not increment unreadCount when local unread count tracking is ' - 'disabled', + ".startTyping should return if we don't have the capability", () async { - final disabledClient = MockStreamChatClient(); - when(() => disabledClient.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - when(() => disabledClient.retryPolicy).thenReturn( - RetryPolicy(shouldRetry: (_, __, ___) => false), - ); - when(() => disabledClient.state).thenReturn(FakeClientState(currentUser: currentUser)); - when(() => disabledClient.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => disabledClient.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - // `isLocalUnreadCountEnabled` defaults to `false` on the mock. - - final channel = _createLivestreamChannel(overrideClient: disabledClient); - - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - - disabledClient.addEvent( - Event(cid: channel.cid, type: EventType.messageNew, message: message), + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no typingEvents capability ); - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(0)); - }, - ); - - test('decrements unreadCount when a counted message is hard-deleted', () async { - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - final channel = _createLivestreamChannel( - messages: [message], - reads: [ - Read( - user: currentUser, - lastRead: message.createdAt.subtract(const Duration(days: 1)), - ), - ], - ); - channel.state!.unreadCount = 1; - expect(channel.state?.unreadCount, equals(1)); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: message, - hardDelete: true, - ), - ); - await Future.delayed(Duration.zero); - - expect(channel.state?.unreadCount, equals(0)); - }); - - test('does not decrement unreadCount when a message is soft-deleted', () async { - final message = Message( - id: 'message-1', - text: 'Hello', - user: User(id: 'other-user'), - createdAt: DateTime(2024, 1, 1), - ); - final channel = _createLivestreamChannel( - messages: [message], - reads: [ - Read( - user: currentUser, - lastRead: message.createdAt.subtract(const Duration(days: 1)), - ), - ], - ); - channel.state!.unreadCount = 1; - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: message, - hardDelete: false, - ), - ); - await Future.delayed(Duration.zero); - expect(channel.state?.unreadCount, equals(1)); - }); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test( - 'markRead resets unreadCount locally without making a network request', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 3; - expect(channel.state?.unreadCount, equals(3)); + final typingStartEvent = Event(type: EventType.typingStart); - await expectLater(channel.markRead(), completes); + await expectLater(channel.startTyping(), completes); - expect(channel.state?.unreadCount, equals(0)); verifyNever( - () => client.markChannelRead( - any(), - any(), - messageId: any(named: 'messageId'), + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), ), ); }, ); test( - 'markUnreadByTimestamp recomputes unreadCount locally without making a ' - 'network request', + '.startTyping should return when user privacy settings is disabled', () async { - final now = DateTime(2024, 1, 1); - final messages = [ - Message( - id: 'm1', - text: '1', - user: User(id: 'other-user'), - createdAt: now, - ), - Message( - id: 'm2', - text: '2', - user: User(id: 'other-user'), - createdAt: now.add(const Duration(minutes: 1)), - ), - Message( - id: 'm3', - text: '3', - user: User(id: 'other-user'), - createdAt: now.add(const Duration(minutes: 2)), + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), ), - ]; - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: now.add(const Duration(minutes: 5))), - ], ); - expect(channel.state?.unreadCount, equals(0)); - await expectLater( - channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), - completes, - ); + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); - // Only m2 and m3 were created after the given timestamp. - expect(channel.state?.unreadCount, equals(2)); - verifyNever( - () => client.markChannelUnreadByTimestamp(any(), any(), any()), + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], ); - }, - ); - - test( - 'markUnread throws when the message is not locally known', - () async { - final channel = _createLivestreamChannel(); - await expectLater( - channel.markUnread('unknown-message-id'), - throwsA(isA()), - ); - verifyNever( - () => client.markChannelUnread(any(), any(), any()), - ); - }, - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test( - 'markRead reconciles pending delivery receipts', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 2; + final typingStartEvent = Event(type: EventType.typingStart); - await expectLater(channel.markRead(), completes); + await expectLater(channel.startTyping(), completes); - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ); }, ); - group('local read boundary anchors', () { - final start = DateTime(2024, 1, 1); - final messages = [ - Message( - id: 'm1', - text: '1', - user: User(id: 'other-user'), - createdAt: start, - ), - Message( - id: 'm2', - text: '2', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 1)), - ), - Message( - id: 'm3', - text: '3', - user: User(id: 'other-user'), - createdAt: start.add(const Duration(minutes: 2)), - ), - ]; - - test( - 'markUnread is inclusive of the anchor and points lastReadMessageId at ' - 'the previous message', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - - await expectLater(channel.markUnread('m2'), completes); - - // m2 (the anchor) and m3 are unread; m1 stays read. - expect(channel.state?.unreadCount, equals(2)); - expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m1')); - verifyNever(() => client.markChannelUnread(any(), any(), any())); - }, + test(".startTyping should send 'typingStart' successfully", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], ); - test( - 'markUnread leaves lastReadMessageId null when the anchor is the oldest ' - 'known message', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - await expectLater(channel.markUnread('m1'), completes); + final typingStartEvent = Event(type: EventType.typingStart); - expect(channel.state?.unreadCount, equals(3)); - expect(channel.state?.currentUserRead?.lastReadMessageId, isNull); - }, - ); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); - test( - 'markUnreadByTimestamp is exclusive of the boundary and points ' - 'lastReadMessageId at the newest message at or before it', - () async { - final channel = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); + await expectLater(channel.startTyping(), completes); - // Exactly m2's createdAt: m2 stays read, only m3 becomes unread. - await expectLater(channel.markUnreadByTimestamp(messages[1].createdAt), completes); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ).called(1); + }); - expect(channel.state?.unreadCount, equals(1)); - expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m2')); - verifyNever(() => client.markChannelUnreadByTimestamp(any(), any(), any())); - }, + test(".stopTyping should return if we don't have the capability", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no typingEvents capability ); - test( - 'markUnread(id) and markUnreadByTimestamp(createdAt) intentionally ' - 'differ by the anchor message', - () async { - final byId = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); - final byTimestamp = _createLivestreamChannel( - messages: messages, - reads: [ - Read(user: currentUser, lastRead: start.add(const Duration(minutes: 5))), - ], - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - await byId.markUnread('m2'); - await byTimestamp.markUnreadByTimestamp(messages[1].createdAt); + final typingStopEvent = Event(type: EventType.typingStop); - // `markUnread` includes m2, `markUnreadByTimestamp` excludes it. - expect(byId.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.unreadCount, equals(1)); + await expectLater(channel.stopTyping(), completes); - // ...and they agree once the timestamp is nudged below the anchor. - await byTimestamp.markUnreadByTimestamp( - messages[1].createdAt.subtract(const Duration(microseconds: 1)), - ); - expect(byTimestamp.state?.unreadCount, equals(2)); - expect(byTimestamp.state?.currentUserRead?.lastReadMessageId, equals('m1')); - }, + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), ); }); test( - 'server payloads do not clobber the locally-tracked read state', + '.stopTyping should return when user privacy settings is disabled', () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 5; - - final serverRead = Read( - user: currentUser, - lastRead: DateTime.now(), - unreadMessages: 0, + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), ); - channel.state!.updateChannelStateFromServer( - channel.state!.channelState.copyWith(read: [serverRead]), + + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); + + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], ); - expect(channel.state?.unreadCount, equals(5)); - }, - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test( - 'local (non-remote) state updates are not affected by the server-merge ' - 'guard', - () async { - final channel = _createLivestreamChannel(); - channel.state!.unreadCount = 5; + final typingStopEvent = Event(type: EventType.typingStop); - // A plain local mutation (via updateChannelState, not - // updateChannelStateFromServer) should still be able to change the - // locally-tracked read state. - await expectLater(channel.markRead(), completes); + await expectLater(channel.stopTyping(), completes); - expect(channel.state?.unreadCount, equals(0)); + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ); }, ); + + test(".stopTyping should send 'typingStop' successfully", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final typingStopEvent = Event(type: EventType.typingStop); + + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater(channel.stopTyping(), completes); + + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ).called(1); + }); }); - group('updateChannelState identity guard', () { + group('Read Receipts', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; late final client = MockStreamChatClient(); setUpAll(() { + // detached loggers when(() => client.detachedLogger(any())).thenAnswer((invocation) { final name = invocation.positionalArguments.first; return _createLogger(name); }); - when(() => client.retryPolicy).thenReturn( - RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ), + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); - when(() => client.state).thenReturn(FakeClientState()); + when(() => client.retryPolicy).thenReturn(retryPolicy); + + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + // client logger when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); }); - Channel _seededChannel() { - final base = _generateChannelState(channelId, channelType); - final now = DateTime.now(); - final seeded = base.copyWith( - messages: [ - Message(id: 'm1', text: '1', createdAt: now), - Message(id: 'm2', text: '2', createdAt: now.add(const Duration(seconds: 1))), - Message(id: 'm3', text: '3', createdAt: now.add(const Duration(seconds: 2))), - ], - ); - return Channel.fromState(client, seeded); - } - test( - 'preserves messages reference when updatedState.messages is null', - () { - final channel = _seededChannel(); + ".markRead should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); + + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final before = channel.state!.messages; - channel.state!.updateChannelState( - ChannelState(channel: channel.state!.channelState.channel), + await expectLater( + channel.markRead(messageId: 'message-id-123'), + throwsA(isA()), ); - final after = channel.state!.messages; - - expect(identical(before, after), isTrue); }, ); test( - 'preserves messages reference when updatedState.messages is identical', - () { - final channel = _seededChannel(); + '.markRead should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], + ); + + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final before = channel.state!.messages; - // copyWith without messages keeps the same `messages` reference, so - // updateChannelState should hit the identity-guard fast path. - channel.state!.updateChannelState( - channel.state!.channelState.copyWith( - read: [ - Read( - user: User(id: 'me'), - lastRead: DateTime.now(), - unreadMessages: 1, - ), - ], + when( + () => client.markChannelRead( + channelId, + channelType, + messageId: 'message-id-123', ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater( + channel.markRead(messageId: 'message-id-123'), + completes, ); - final after = channel.state!.messages; - expect(identical(before, after), isTrue); + verify( + () => client.markChannelRead( + channelId, + channelType, + messageId: 'message-id-123', + ), + ).called(1); }, ); test( - 'still merges messages when updatedState.messages is a different list', - () { - final channel = _seededChannel(); + ".markUnread should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); + + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final newMessage = Message( - id: 'm4', - text: '4', - createdAt: DateTime.now().add(const Duration(seconds: 10)), + await expectLater( + channel.markUnread('message-id-123'), + throwsA(isA()), ); - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: [newMessage], - ), + }, + ); + + test( + '.markUnread should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], ); - expect( - channel.state!.messages.map((m) => m.id), - ['m1', 'm2', 'm3', 'm4'], + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + when( + () => client.markChannelUnread( + channelId, + channelType, + 'message-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater( + channel.markUnread('message-id-123'), + completes, ); + + verify( + () => client.markChannelUnread( + channelId, + channelType, + 'message-id-123', + ), + ).called(1); }, ); - test('cold-path merge interleaves new messages in sorted order', () { - final channel = _seededChannel(); - addTearDown(channel.dispose); + test( + ".markUnreadByTimestamp should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); - final base = channel.state!.messages.first.createdAt; - // Incoming list is sorted ascending by createdAt and slots between - // the existing m1, m2, m3. - final incoming = [ - Message( - id: 'm1.5', - text: 'between m1 and m2', - createdAt: base.add(const Duration(milliseconds: 500)), - ), - Message( - id: 'm2.5', - text: 'between m2 and m3', - createdAt: base.add(const Duration(milliseconds: 1500)), - ), - ]; - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: incoming, - ), - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - expect( - channel.state!.messages.map((m) => m.id), - ['m1', 'm1.5', 'm2', 'm2.5', 'm3'], - ); - }); + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - test('cold-path merge runs syncWith on overlapping ids', () { - final channel = _seededChannel(); - addTearDown(channel.dispose); + await expectLater( + channel.markUnreadByTimestamp(timestamp), + throwsA(isA()), + ); + }, + ); - final localStamp = DateTime.now(); - // Seed m2 with a localCreatedAt that the incoming version doesn't - // carry, so we can verify syncWith fired during the merge. - channel.state!.updateMessage( - Message( - id: 'm2', - text: '2', - createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, - ).copyWith(localCreatedAt: localStamp), - ); + test( + '.markUnreadByTimestamp should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], + ); - final incoming = [ - Message( - id: 'm2', - text: '2 (server)', - createdAt: channel.state!.messages.firstWhere((m) => m.id == 'm2').createdAt, - ), - ]; - channel.state!.updateChannelState( - ChannelState( - channel: channel.state!.channelState.channel, - messages: incoming, - ), - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - final m2 = channel.state!.messages.firstWhere((m) => m.id == 'm2'); - expect(m2.text, '2 (server)'); - // Local-only field carried over by syncWith during the merge. - expect(m2.localCreatedAt, localStamp); - }); - }); + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - group('updateMessage quoted-rewrite', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); + when( + () => client.markChannelUnreadByTimestamp( + channelId, + channelType, + timestamp, + ), + ).thenAnswer((_) async => EmptyResponse()); - setUpAll(() { - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - when(() => client.retryPolicy).thenReturn( - RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ), - ); - when(() => client.state).thenReturn(FakeClientState()); - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); + await expectLater( + channel.markUnreadByTimestamp(timestamp), + completes, + ); - Channel _seededChannel({required List messages}) { - final base = _generateChannelState(channelId, channelType); - return Channel.fromState(client, base.copyWith(messages: messages)); - } + verify( + () => client.markChannelUnreadByTimestamp( + channelId, + channelType, + timestamp, + ), + ).called(1); + }, + ); test( - 'rewrites quotedMessage on every quoter when target is deleted', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'hi', createdAt: now); - final quoter1 = Message( - id: 'q1', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), + ".markThreadRead should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability ); - final unrelated = Message( - id: 'u1', - text: 'other', - createdAt: now.add(const Duration(seconds: 2)), + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + await expectLater( + channel.markThreadRead('thread-id-123'), + throwsA(isA()), ); - final quoter2 = Message( - id: 'q2', - text: 'reply2', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 3)), + }, + ); + + test( + '.markThreadRead should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], ); - final channel = _seededChannel(messages: [target, quoter1, unrelated, quoter2]); + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final unrelatedBefore = channel.state!.messages.firstWhere((m) => m.id == 'u1'); + when( + () => client.markThreadRead( + channelId, + channelType, + 'thread-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), + await expectLater( + channel.markThreadRead('thread-id-123'), + completes, ); - channel.state!.updateMessage(deleted); - - final after = channel.state!.messages; - final q1After = after.firstWhere((m) => m.id == 'q1'); - final q2After = after.firstWhere((m) => m.id == 'q2'); - final uAfter = after.firstWhere((m) => m.id == 'u1'); - - expect(q1After.quotedMessage?.deletedAt, isNotNull); - expect(q1After.quotedMessage?.type, MessageType.deleted); - expect(q2After.quotedMessage?.deletedAt, isNotNull); - expect(q2After.quotedMessage?.type, MessageType.deleted); - // Unrelated messages must not be rebuilt by the rewrite. - expect(identical(uAfter, unrelatedBefore), isTrue); + + verify( + () => client.markThreadRead( + channelId, + channelType, + 'thread-id-123', + ), + ).called(1); }, ); test( - 'preserves messages reference when no message quotes the deleted one', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'hi', createdAt: now); - final unrelated = Message( - id: 'u1', - text: 'other', - createdAt: now.add(const Duration(seconds: 1)), + ".markThreadUnread should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability ); - final channel = _seededChannel(messages: [target, unrelated]); + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final deleted = target.copyWith( - type: MessageType.deleted, - deletedAt: now.add(const Duration(seconds: 5)), + await expectLater( + channel.markThreadUnread('thread-id-123'), + throwsA(isA()), ); - channel.state!.updateMessage(deleted); - - // No message quotes `target`, so `updateIf` short-circuits and the - // remaining messages keep their identities (only `target` itself was - // replaced by `sortedUpsert`). - final unrelatedAfter = channel.state!.messages.firstWhere((m) => m.id == 'u1'); - expect(identical(unrelatedAfter, unrelated), isTrue); }, ); test( - 'does not rewrite quotes when an existing quoted target is updated ' - 'without being deleted', - () { - final now = DateTime.now(); - final target = Message(id: 'target', text: 'original', createdAt: now); - final quoter = Message( - id: 'q1', - text: 'reply', - quotedMessageId: 'target', - quotedMessage: target, - createdAt: now.add(const Duration(seconds: 1)), + '.markThreadUnread should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], ); - final channel = _seededChannel(messages: [target, quoter]); + final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final quoterBefore = channel.state!.messages.firstWhere((m) => m.id == 'q1'); + when( + () => client.markThreadUnread( + channelId, + channelType, + 'thread-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); - // Plain text update — not a deletion. - channel.state!.updateMessage(target.copyWith(text: 'edited')); + await expectLater( + channel.markThreadUnread('thread-id-123'), + completes, + ); - final quoterAfter = channel.state!.messages.firstWhere((m) => m.id == 'q1'); - // `updateIf` is gated on `message.isDeleted`, so the quoter must keep - // its identity (no allocation, no quoted-message overwrite). - expect(identical(quoterAfter, quoterBefore), isTrue); + verify( + () => client.markThreadUnread( + channelId, + channelType, + 'thread-id-123', + ), + ).called(1); }, ); }); @@ -11971,386 +6684,4 @@ void main() { }); }); }); - - group('Message enrichment preservation on merge', () { - late final client = MockStreamChatClient(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUpAll(() { - registerFallbackValue(FakeMessage()); - registerFallbackValue([]); - - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); - - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); - - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - }); - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); - - tearDown(() { - channel.dispose(); - clearInteractions(client); - }); - - test( - 'preserves the `poll` on a quotedMessage when the server omits it during ' - 're-sync (regression: poll quote disappears after foregrounding)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-1', - name: 'Pizza or pasta?', - options: const [ - PollOption(id: 'opt-1', text: 'Pizza'), - PollOption(id: 'opt-2', text: 'Pasta'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-1', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-1', - text: 'Voting now', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - // Seed channel state with the fully-enriched messages (mirrors what - // the local DB load produces). - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate a re-sync from the API: the server echoes the reply with - // a `quoted_message` that has only `poll_id` (no `poll` object). - // Constructed directly (not via copyWith) because copyWith cannot - // clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.id, pollMessage.id); - expect(mergedReply.quotedMessage!.poll, isNotNull); - expect(mergedReply.quotedMessage!.poll!.id, poll.id); - expect(mergedReply.quotedMessage!.poll!.name, poll.name); - }, - ); - - test( - 'preserves a nested quotedMessage (poll) two levels deep when the ' - 'server omits it during re-sync (regression: quote-of-quote of a poll ' - 'disappears completely after foregrounding)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-2', - name: 'Coffee or tea?', - options: const [ - PollOption(id: 'opt-a', text: 'Coffee'), - PollOption(id: 'opt-b', text: 'Tea'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-2', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-A', - text: 'My pick', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'user-a'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - final replyToReply = Message( - id: 'reply-B', - text: 'Same here', - quotedMessageId: replyToPoll.id, - quotedMessage: replyToPoll, - user: User(id: 'user-b'), - createdAt: DateTime.utc(2026, 4, 29, 12), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll, replyToReply], - ), - ); - - // Simulate the server response where: - // - replyA's nested quoted poll is missing the `poll` object. - // - replyB's nested quoted replyA is missing its own `quoted_message` - // (the server typically does not nest two levels deep). - // Stripped poll snapshot is constructed directly because copyWith - // cannot clear `poll` — see Message.copyWith. - final strippedPollSnapshot = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - ); - final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); - - final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], - ), - ); - - final mergedReplyA = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - final mergedReplyB = channel.state?.messages.firstWhere((it) => it.id == replyToReply.id); - - // First-level quote (reply A's quote of the poll) must keep the poll. - expect(mergedReplyA?.quotedMessage?.poll, isNotNull); - expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); - - // Second-level quote (reply B's quote of reply A) must keep reply A's - // own nested quotedMessage so the poll preview still resolves. - expect(mergedReplyB?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); - expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); - }, - ); - - test( - 'still preserves quotedMessage when the updated payload has no ' - 'quoted_message at all (existing behavior should not regress)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-3', - name: 'Beach or mountains?', - options: const [ - PollOption(id: 'opt-x', text: 'Beach'), - PollOption(id: 'opt-y', text: 'Mountains'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'poll-msg-3', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - final replyToPoll = Message( - id: 'reply-3', - text: 'Definitely beach', - quotedMessageId: pollMessage.id, - quotedMessage: pollMessage, - user: User(id: 'reply-user'), - createdAt: DateTime.utc(2026, 4, 29, 11), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage, replyToPoll], - ), - ); - - // Simulate an update event that touches the reply but doesn't echo - // the nested quoted_message at all (only quotedMessageId is set). - final reSyncedReply = Message( - id: replyToPoll.id, - text: 'Definitely beach (edited)', - quotedMessageId: pollMessage.id, - user: replyToPoll.user, - createdAt: replyToPoll.createdAt, - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [reSyncedReply], - ), - ); - - final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - - expect(mergedReply, isNotNull); - expect(mergedReply!.text, 'Definitely beach (edited)'); - expect(mergedReply.quotedMessage, isNotNull); - expect(mergedReply.quotedMessage!.poll?.id, poll.id); - }, - ); - - test( - 'preserves the top-level `poll` when the server emits a `message.updated`' - ' that omits the `poll` object (regression: poll disappears from the ' - 'parent message after a thread reply is added)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-thread', - name: 'What is for lunch?', - options: const [ - PollOption(id: 'opt-1', text: 'Burgers'), - PollOption(id: 'opt-2', text: 'Salads'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'parent-poll-msg', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - replyCount: 0, - ); - - // Seed channel state with the fully-enriched parent poll message. - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - // Simulate the `message.updated` event the backend fires for the - // parent after a thread reply is added: bookkeeping fields are bumped - // (`reply_count`, `updated_at`) but the `poll` object is omitted from - // the payload — only `pollId` is set. Constructed directly because - // copyWith cannot clear `poll` — see Message.copyWith. - final strippedParentUpdate = Message( - id: pollMessage.id, - pollId: pollMessage.pollId, - user: pollUser, - createdAt: pollMessage.createdAt, - replyCount: 1, - updatedAt: DateTime.utc(2026, 4, 29, 11), - ); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: strippedParentUpdate, - ), - ); - - // Wait for the event to be processed. - await Future.delayed(Duration.zero); - - final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); - - // Parent poll message must remain in the channel state after a thread reply. - expect(merged, isNotNull); - // Bookkeeping fields from the event should still apply. - expect(merged!.replyCount, 1); - // Locally-known poll must be preserved when the server omits it from a - // `message.updated` payload (e.g. when a thread reply bumps reply_count). - expect(merged.poll, isNotNull); - expect(merged.poll!.id, poll.id); - expect(merged.poll!.name, poll.name); - expect(merged.pollId, poll.id); - }, - ); - - test( - 'still uses the updated `poll` when the server includes one in ' - '`message.updated` (poll edits should not be reverted to the locally ' - 'cached version)', - () async { - final pollUser = User(id: 'poll-author'); - final poll = Poll( - id: 'poll-edit', - name: 'Initial name', - options: const [ - PollOption(id: 'opt-1', text: 'Original A'), - ], - createdById: pollUser.id, - ); - - final pollMessage = Message( - id: 'edit-parent', - poll: poll, - pollId: poll.id, - user: pollUser, - createdAt: DateTime.utc(2026, 4, 29, 10), - ); - - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - messages: [pollMessage], - ), - ); - - final updatedPoll = poll.copyWith(name: 'Edited name'); - final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); - - client.addEvent( - Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: updatedParent, - ), - ); - - await Future.delayed(Duration.zero); - - final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); - - // Server-echoed poll must override the locally cached one — poll edits - // should not be reverted by the local-fallback merge. - expect(merged?.poll, isNotNull); - expect(merged?.poll?.name, 'Edited name'); - }, - ); - }); } diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart index 27a3b823ac..8a82ea566b 100644 --- a/packages/stream_chat/test/src/matchers.dart +++ b/packages/stream_chat/test/src/matchers.dart @@ -1,6 +1,6 @@ import 'package:collection/collection.dart'; import 'package:dio/dio.dart' show MultipartFile; -import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/channel/channel.dart'; import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/draft_message.dart'; diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 848f26a48d..e248766ac3 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/channel/channel.dart'; import 'package:stream_chat/src/client/channel_delivery_reporter.dart'; import 'package:stream_chat/src/client/client.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart';