diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 6b6488f1be..dc07df3793 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -4,6 +4,13 @@ - Added support for user-level privacy settings via `OwnUser.privacySettings`. - Added `invisible` field to `User` and `OwnUser` models. +- Added message delivery receipts support with `lastDeliveredAt` and `lastDeliveredMessageId` fields + in `Read` model. +- Added `Client.markChannelsDelivered` method to submit delivery receipts. +- Added `deliveriesOf` and `readsOf` helper methods to `ReadIterableExtension` for querying read and + delivery statuses. +- Added channel capability getters: `Channel.canUseDeliveryReceipts`, `Channel.canUseReadReceipts`, + `Channel.canUseTypingEvents`. โš ๏ธ Deprecated diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index ccea62b117..d7b85a3956 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -94,9 +94,7 @@ class Channel { _type = channelState.channel!.type, _cid = channelState.channel!.cid, _extraData = channelState.channel!.extraData { - state = ChannelClientState(this, channelState); - _initializedCompleter.complete(true); - _client.logger.info('New Channel instance initialized'); + _initState(channelState); // Initialize the state immediately. } /// This client state @@ -676,15 +674,6 @@ class Channel { }); } - bool _isMessageValidForUpload(Message message) { - final hasText = message.text?.trim().isNotEmpty == true; - final hasAttachments = message.attachments.isNotEmpty; - final hasQuotedMessage = message.quotedMessageId != null; - final hasPoll = message.pollId != null; - - return hasText || hasAttachments || hasQuotedMessage || hasPoll; - } - final _sendMessageLock = Lock(); /// Send a [message] to this channel. @@ -744,7 +733,7 @@ class Channel { } // Validate the final message before sending it to the server. - if (_isMessageValidForUpload(message) == false) { + 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. @@ -1642,7 +1631,7 @@ class Channel { Future markRead({String? messageId}) async { _checkInitialized(); - if (!canReceiveReadEvents) { + if (!canUseReadReceipts) { throw const StreamChatError( 'Cannot mark as read: Channel does not support read events. ' 'Enable read_events in your channel type configuration.', @@ -1659,7 +1648,7 @@ class Channel { Future markUnread(String messageId) async { _checkInitialized(); - if (!canReceiveReadEvents) { + if (!canUseReadReceipts) { throw const StreamChatError( 'Cannot mark as unread: Channel does not support read events. ' 'Enable read_events in your channel type configuration.', @@ -1673,7 +1662,7 @@ class Channel { Future markThreadRead(String threadId) async { _checkInitialized(); - if (!canReceiveReadEvents) { + if (!canUseReadReceipts) { throw const StreamChatError( 'Cannot mark thread as read: Channel does not support read events. ' 'Enable read_events in your channel type configuration.', @@ -1687,7 +1676,7 @@ class Channel { Future markThreadUnread(String threadId) async { _checkInitialized(); - if (!canReceiveReadEvents) { + if (!canUseReadReceipts) { throw const StreamChatError( 'Cannot mark thread as unread: Channel does not support read events. ' 'Enable read_events in your channel type configuration.', @@ -1699,13 +1688,10 @@ class Channel { void _initState(ChannelState channelState) { state = ChannelClientState(this, channelState); + _initializedCompleter.safeComplete(true); - if (cid != null) { - client.state.addChannels({cid!: this}); - } - if (!_initializedCompleter.isCompleted) { - _initializedCompleter.complete(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. @@ -1879,6 +1865,12 @@ class Channel { this.state?.updateChannelState(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 @@ -1894,9 +1886,7 @@ class Channel { } // Otherwise, we will just rethrow the error. - if (!_initializedCompleter.isCompleted) { - _initializedCompleter.completeError(e, stk); - } + _initializedCompleter.safeCompleteError(e, stk); rethrow; } @@ -2102,9 +2092,9 @@ class Channel { // privacy settings. bool get _canSendTypingEvents { final currentUser = client.state.currentUser; - final typingIndicatorsEnabled = currentUser?.isTypingIndicatorsEnabled; + if (currentUser == null) return false; - return canUseTypingEvents && (typingIndicatorsEnabled ?? true); + return canUseTypingEvents && currentUser.isTypingIndicatorsEnabled; } /// Sends the [Event.typingStart] event and schedules a timer to invoke the @@ -2169,8 +2159,8 @@ class ChannelClientState { ) { _retryQueue = RetryQueue( channel: _channel, - logger: _channel.client.detachedLogger( - 'โŸณ (${generateHash([_channel.cid])})', + logger: _client.detachedLogger( + '๐Ÿ”„ (${generateHash([_channel.cid])})', ), ); @@ -2256,7 +2246,7 @@ class ChannelClientState { _listenChannelPushPreferenceUpdated(); - final persistenceClient = _channel.client.chatPersistenceClient; + 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; @@ -2264,6 +2254,7 @@ class ChannelClientState { } final Channel _channel; + StreamChatClient get _client => _channel._client; final _subscriptions = CompositeSubscription(); void _checkExpiredAttachmentMessages(ChannelState channelState) async { @@ -2297,7 +2288,7 @@ class ChannelClientState { if (expiredAttachmentMessagesId != null && expiredAttachmentMessagesId.isNotEmpty) { - await _channel._initializedCompleter.future; + await _channel.initialized; _updatedMessagesIds.addAll(expiredAttachmentMessagesId); _channel.getMessagesById(expiredAttachmentMessagesId); } @@ -2417,8 +2408,7 @@ class ChannelClientState { .on(EventType.channelTruncated, EventType.notificationChannelTruncated) .listen((event) async { final channel = event.channel!; - await _channel._client.chatPersistenceClient - ?.deleteMessageByCid(channel.cid); + await _client.chatPersistenceClient?.deleteMessageByCid(channel.cid); truncate(); if (event.message != null) { updateMessage(event.message!); @@ -2597,7 +2587,7 @@ class ChannelClientState { eventPollVote.id!: eventPollVote, }; - final currentUserId = _channel.client.state.currentUser?.id; + final currentUserId = _client.state.currentUser?.id; final ownVotesAndAnswers = { for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, if (eventPollVote.userId == currentUserId) @@ -2625,7 +2615,7 @@ class ChannelClientState { final oldPoll = pollMessage.poll; final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _channel.client.state.currentUser?.id; + final currentUserId = _client.state.currentUser?.id; final ownVotesAndAnswers = { for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, if (eventPollVote.userId == currentUserId) @@ -2706,7 +2696,7 @@ class ChannelClientState { final oldPoll = pollMessage.poll; final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _channel.client.state.currentUser?.id; + final currentUserId = _client.state.currentUser?.id; final ownVotesAndAnswers = { for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, if (eventPollVote.userId == currentUserId) @@ -2879,8 +2869,8 @@ class ChannelClientState { if (message == null) return; final isThreadMessage = message.parentId != null; - final isShownInChannel = message.showInChannel == true; - final isThreadOnlyMessage = isThreadMessage && !isShownInChannel; + 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. @@ -2889,28 +2879,12 @@ class ChannelClientState { } // Otherwise, check if we can count the message as unread. - if (_countMessageAsUnread(message)) unreadCount += 1; - })); - } - - // Logic taken from the backend SDK - // https://github.com/GetStream/chat/blob/9245c2b3f7e679267d57ee510c60e93de051cb8e/types/channel.go#L1136-L1150 - bool _shouldUpdateChannelLastMessageAt(Message message) { - if (message.isError) return false; - if (message.shadowed) return false; - if (message.isEphemeral) return false; - - final config = channelState.channel?.config; - if (message.isSystem && config?.skipLastMsgUpdateForSystemMsgs == true) { - return false; - } - - final currentUserId = _channel._client.state.currentUser?.id; - if (currentUserId case final userId? when message.isNotVisibleTo(userId)) { - return false; - } + if (MessageRules.canCountAsUnread(message, _channel)) { + unreadCount += 1; // Increment unread count + } - return true; + _client.channelDeliveryReporter.submitForDelivery([_channel]); + })); } /// Updates the [read] in the state if it exists. Adds it otherwise. @@ -2951,7 +2925,7 @@ class ChannelClientState { /// Deletes the [draft] from the state if it exists. void deleteDraft(Draft draft) async { // Delete the draft from the persistence client. - await _channel._client.chatPersistenceClient?.deleteDraftMessageByCid( + await _client.chatPersistenceClient?.deleteDraftMessageByCid( draft.channelCid, parentId: draft.parentId, ); @@ -3028,7 +3002,7 @@ class ChannelClientState { // Calculate the new last message at time. var lastMessageAt = _channelState.channel?.lastMessageAt; lastMessageAt ??= message.createdAt; - if (_shouldUpdateChannelLastMessageAt(message)) { + if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) { lastMessageAt = [lastMessageAt, message.createdAt].max; } @@ -3084,7 +3058,7 @@ class ChannelClientState { /// Remove a [message] from this [channelState]. void removeMessage(Message message) async { - await _channel._client.chatPersistenceClient?.deleteMessageById(message.id); + await _client.chatPersistenceClient?.deleteMessageById(message.id); final parentId = message.parentId; // i.e. it's a thread message, Remove it @@ -3134,43 +3108,85 @@ class ChannelClientState { void _listenReadEvents() { _subscriptions ..add( - _channel - .on( - EventType.messageRead, - EventType.notificationMarkRead, - ) - .listen( + _channel.on(EventType.messageRead).listen( (event) { final user = event.user; if (user == null) return; + final currentRead = userReadOf(userId: user.id); + final updatedRead = Read( user: user, lastRead: event.createdAt, - unreadMessages: event.unreadMessages, + 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, ); - return updateRead([updatedRead]); + 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 event) { + (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]); + } + }, + ), ); } @@ -3201,25 +3217,23 @@ class ChannelClientState { .distinct(const ListEquality().equals); /// Get channel last message. - Message? get lastMessage => - _channelState.messages != null && _channelState.messages!.isNotEmpty - ? _channelState.messages!.last - : null; + Message? get lastMessage => messages.lastOrNull; - /// Get channel last message. - Stream get lastMessageStream => - messagesStream.map((event) => event.isNotEmpty ? event.last : null); + /// 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: _channel.client.state.users[e.user!.id])) + .map((e) => e.copyWith(user: _client.state.users[e.user!.id])) .toList(); /// Channel members list as a stream. Stream> get membersStream => CombineLatestStream.combine2< List?, Map, List>( channelStateStream.map((cs) => cs.members), - _channel.client.state.usersStream, + _client.state.usersStream, (members, users) => [...?members?.map((e) => e!.copyWith(user: users[e.user!.id]))], ).distinct(const ListEquality().equals); @@ -3233,14 +3247,14 @@ class ChannelClientState { /// Channel watchers list. List get watchers => (_channelState.watchers ?? []) - .map((e) => _channel.client.state.users[e.id] ?? e) + .map((e) => _client.state.users[e.id] ?? e) .toList(); /// Channel watchers list as a stream. Stream> get watchersStream => CombineLatestStream.combine2< List?, Map, List>( channelStateStream.map((cs) => cs.watchers), - _channel.client.state.usersStream, + _client.state.usersStream, (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], ).distinct(const ListEquality().equals); @@ -3254,7 +3268,7 @@ class ChannelClientState { /// Channel member for the current user. Member? get currentUserMember => members.firstWhereOrNull( - (m) => m.user?.id == _channel.client.state.currentUser?.id, + (m) => m.user?.id == _client.state.currentUser?.id, ); /// Channel role for the current user @@ -3264,29 +3278,33 @@ class ChannelClientState { List get read => _channelState.read ?? []; /// Channel read list as a stream. - Stream> get readStream => - channelStateStream.map((cs) => cs.read ?? []); - - bool _isCurrentUserRead(Read read) => - read.user.id == _channel._client.state.currentUser!.id; + Stream> get readStream { + return channelStateStream.map((cs) => cs.read ?? []); + } /// Channel read for the logged in user. - Read? get currentUserRead => read.firstWhereOrNull(_isCurrentUserRead); + Read? get currentUserRead { + final currentUser = _client.state.currentUser; + return userReadOf(userId: currentUser?.id); + } /// Channel read for the logged in user as a stream. - Stream get currentUserReadStream => - readStream.map((read) => read.firstWhereOrNull(_isCurrentUserRead)); + Stream get currentUserReadStream { + final currentUser = _client.state.currentUserStream; + return currentUser.switchMap((it) => userReadStreamOf(userId: it?.id)); + } /// Unread count getter as a stream. - Stream get unreadCountStream => - currentUserReadStream.map((read) => read?.unreadMessages ?? 0); + Stream get unreadCountStream { + return currentUserReadStream.map((read) => read?.unreadMessages ?? 0); + } /// Unread count getter. int get unreadCount => currentUserRead?.unreadMessages ?? 0; /// Setter for unread count. set unreadCount(int count) { - final currentUser = _channel.client.state.currentUser; + final currentUser = _client.state.currentUser; if (currentUser == null) return; var existingUserRead = currentUserRead; @@ -3301,74 +3319,22 @@ class ChannelClientState { return updateRead([existingUserRead.copyWith(unreadMessages: count)]); } - bool _countMessageAsUnread(Message message) { - // Don't count if the channel doesn't allow read events. - if (!_channel.canReceiveReadEvents) return false; - - // Don't count if the channel is muted. - if (_channel.isMuted) return false; - - // Don't count if the message is silent or shadowed or ephemeral. - if (message.silent) return false; - if (message.shadowed) return false; - if (message.isEphemeral) return false; - - // Don't count thread replies which are not shown in the channel as unread. - if (message.parentId != null && message.showInChannel != true) { - return false; - } - - // Don't count if the message doesn't have a user. - final messageUser = message.user; - if (messageUser == null) return false; - - // Don't count if the current user is not set. - final currentUser = _channel.client.state.currentUser; - if (currentUser == null) return false; - - // Don't count user's own messages as unread. - if (messageUser.id == currentUser.id) return false; - - // Don't count restricted messages as unread. - if (message.isNotVisibleTo(currentUser.id)) return false; - - // Don't count messages from muted users as unread. - final isMuted = currentUser.mutes.any((it) => it.user.id == messageUser.id); - if (isMuted) return false; - - final lastRead = currentUserRead?.lastRead; - // Don't count messages created before the last read time as unread. - if (lastRead case final read? when message.createdAt.isBefore(read)) { - return false; - } - - final lastReadMessageId = currentUserRead?.lastReadMessageId; - // Don't count if the last read message id is the same as the message id. - if (lastReadMessageId case final id? when message.id == id) { - return false; - } - - // If we've passed all checks, count the message as unread. - return true; - } - /// 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 lastRead = currentUserRead?.lastRead; - final userId = _channel.client.state.currentUser?.id; + final currentUserId = _client.state.currentUser?.id; var count = 0; for (final message in messages) { - if (_countMessageAsUnread(message) && - (lastRead == null || message.createdAt.isAfter(lastRead)) && - message.mentionedUsers.any((user) => user.id == userId) == true) { - count++; - } + if (!MessageRules.canCountAsUnread(message, _channel)) continue; + if (!message.mentionedUsers.any((it) => it.id == currentUserId)) continue; + + count++; } + return count; } @@ -3444,7 +3410,7 @@ class ChannelClientState { late final _debouncedUpdatePersistenceChannelState = debounce( (ChannelState state) { - final persistenceClient = _channel._client.chatPersistenceClient; + final persistenceClient = _client.chatPersistenceClient; return persistenceClient?.updateChannelState(state); }, const Duration(seconds: 1), @@ -3460,7 +3426,7 @@ class ChannelClientState { final channelCid = _channel.cid; if (channelCid == null) return; - final persistenceClient = _channel._client.chatPersistenceClient; + final persistenceClient = _client.chatPersistenceClient; return persistenceClient?.updateChannelThreads(channelCid, threads); }, const Duration(seconds: 1), @@ -3535,14 +3501,11 @@ class ChannelClientState { final user = event.user; if (user == null) return; - final currentUser = _channel.client.state.currentUser; - if (currentUser == null) return; + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) return; - if (user.id != currentUser.id) { - final events = {...typingEvents}; - events[user] = event; - _typingEventsController.safeAdd(events); - } + final events = {...typingEvents, user: event}; + _typingEventsController.safeAdd(events); }, ), ) @@ -3552,13 +3515,11 @@ class ChannelClientState { final user = event.user; if (user == null) return; - final currentUser = _channel.client.state.currentUser; - if (currentUser == null) return; + final currentUser = _client.state.currentUser; + if (event.isFromUser(userId: currentUser?.id)) return; - if (user.id != currentUser.id) { - final events = {...typingEvents}..remove(user); - _typingEventsController.safeAdd(events); - } + final events = {...typingEvents}..remove(user); + _typingEventsController.safeAdd(events); }, ), ); @@ -3577,7 +3538,7 @@ class ChannelClientState { typingEvents.forEach((user, event) { if (now.difference(event.createdAt).inSeconds > incomingTypingStartEventTimeout) { - _channel.client.handleEvent( + _client.handleEvent( Event( type: EventType.typingStop, user: user, @@ -3657,6 +3618,57 @@ bool _pinIsValid(Message message) { return message.pinExpires!.isAfter(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 @@ -3802,7 +3814,11 @@ extension ChannelCapabilityCheck on Channel { } /// True, if the current user has read events capability. - bool get canReceiveReadEvents { + @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); } @@ -3841,4 +3857,9 @@ extension ChannelCapabilityCheck on Channel { 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); + } } diff --git a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart new file mode 100644 index 0000000000..001f19625a --- /dev/null +++ b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart @@ -0,0 +1,200 @@ +import 'package:logging/logging.dart'; +import 'package:rate_limiter/rate_limiter.dart'; +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_delivery.dart'; +import 'package:stream_chat/src/core/util/message_rules.dart'; +import 'package:synchronized/synchronized.dart'; + +/// A callback that sends delivery receipts for multiple channels. +/// +/// Each [MessageDeliveryInfo] represents an acknowledgment that the current +/// user has received a message. +typedef MarkChannelsDelivered = Future Function( + Iterable deliveries, +); + +/// Manages the delivery reporting for channel messages. +/// +/// Collects channels that need delivery acknowledgments and efficiently +/// reports them to the server. +class ChannelDeliveryReporter { + /// Creates a new channel delivery reporter. + /// + /// The [onMarkChannelsDelivered] callback is invoked when delivery receipts + /// are ready to be sent to the server. + /// + /// The [throttleDuration] controls how frequently delivery receipts are sent. + /// + /// The optional [logger] logs warnings and errors during operation. + ChannelDeliveryReporter({ + Logger? logger, + required this.onMarkChannelsDelivered, + Duration throttleDuration = const Duration(seconds: 1), + }) : _logger = logger, + _markAsDeliveredThrottleDuration = throttleDuration; + + final Logger? _logger; + final Duration _markAsDeliveredThrottleDuration; + + /// The callback invoked to send delivery receipts. + /// + /// Receives delivery receipts acknowledging that messages were received. + final MarkChannelsDelivered onMarkChannelsDelivered; + + final _deliveryCandidatesLock = Lock(); + final _deliveryCandidates = {}; + + /// Submits [channels] for delivery reporting. + /// + /// Marks each channel's last message as delivered if it meets the delivery + /// requirements according to [MessageRules.canMarkAsDelivered]. Channels + /// without a valid cid or last message are skipped. + /// + /// Typically used after message.new events or initial channel queries. For + /// read/delivered events see [reconcileDelivery], for hidden/left channels + /// see [cancelDelivery]. + Future submitForDelivery(Iterable channels) async { + await _deliveryCandidatesLock.synchronized(() { + for (final channel in channels) { + final channelCid = channel.cid; + if (channelCid == null) continue; + + final lastMessage = channel.state?.lastMessage; + if (lastMessage == null) continue; + + // Only submit for delivery if the message can be marked as delivered. + if (!MessageRules.canMarkAsDelivered(lastMessage, channel)) continue; + + _logger?.fine( + 'Submitted channel $channelCid for delivery ' + '(message: ${lastMessage.id})', + ); + + // Update the latest message for the channel + _deliveryCandidates[channelCid] = lastMessage; + } + }); + + // Trigger mark channels delivered request + _throttledMarkCandidatesAsDelivered.call(); + } + + /// Reconciles delivery reporting for [channels] with their current state. + /// + /// Re-evaluates whether messages still need to be marked as delivered based + /// on the channel's current state. Stops tracking messages that are already + /// read, delivered, or otherwise don't need delivery reporting. + /// + /// This prevents duplicate delivery reports when a message is marked + /// delivered on another device, and avoids unnecessary reports when a user + /// reads a channel (since read supersedes delivered). + /// + /// Typically used after message.read or message.delivered events. See + /// [cancelDelivery] to remove channels entirely, or [submitForDelivery] + /// to add new messages. + /// + /// ```dart + /// // After a message.read or message.delivered event + /// reporter.reconcileDelivery([channel]); + /// ``` + Future reconcileDelivery(Iterable channels) async { + return _deliveryCandidatesLock.synchronized(() { + for (final channel in channels) { + final channelCid = channel.cid; + if (channelCid == null) continue; + + // Get the existing candidate message + final message = _deliveryCandidates[channelCid]; + if (message == null) continue; + + // If the message can still be marked as delivered, keep it + if (MessageRules.canMarkAsDelivered(message, channel)) continue; + + _logger?.fine( + 'Reconciled delivery for channel $channelCid ' + '(message: ${message.id}), removing from candidates', + ); + + // Otherwise, remove it from the candidates + _deliveryCandidates.remove(channelCid); + } + }); + } + + /// Cancels pending delivery reports for [channels]. + /// + /// Prevents the specified channels from being marked as delivered. Typically + /// used when channels are hidden, left, or removed from view. + /// + /// See [reconcileDelivery] to re-evaluate based on current read/delivered + /// state instead of removing channels entirely. + Future cancelDelivery(Iterable channels) { + return _deliveryCandidatesLock.synchronized(() { + for (final channelCid in channels) { + if (!_deliveryCandidates.containsKey(channelCid)) continue; + + final message = _deliveryCandidates.remove(channelCid); + + _logger?.fine( + 'Canceled delivery for channel $channelCid ' + '(message: ${message?.id})', + ); + } + }); + } + + late final _throttledMarkCandidatesAsDelivered = Throttle( + leading: false, + _markCandidatesAsDelivered, + _markAsDeliveredThrottleDuration, + ); + + static const _maxCandidatesPerBatch = 100; + Future _markCandidatesAsDelivered() async { + // We only process at-most 100 channels at a time to avoid large payloads. + final batch = {..._deliveryCandidates}.entries.take(_maxCandidatesPerBatch); + final messageDeliveries = batch.map( + (it) => MessageDelivery(channelCid: it.key, messageId: it.value.id), + ); + + if (messageDeliveries.isEmpty) return; + + _logger?.info('Marking ${messageDeliveries.length} channels as delivered'); + + try { + await onMarkChannelsDelivered(messageDeliveries); + + // Clear the successfully delivered candidates. If a channel's message ID + // has changed since we started delivery, keep it for the next batch. + await _deliveryCandidatesLock.synchronized(() { + for (final delivery in messageDeliveries) { + final deliveredChannelCid = delivery.channelCid; + final deliveredMessageId = delivery.messageId; + + final currentMessage = _deliveryCandidates[deliveredChannelCid]; + // Skip removal if a newer message has been added while we were + // processing the current batch. + if (currentMessage?.id != deliveredMessageId) continue; + _deliveryCandidates.remove(deliveredChannelCid); + } + + // Schedule the next batch if there are remaining candidates. + if (_deliveryCandidates.isNotEmpty) { + _throttledMarkCandidatesAsDelivered.call(); + } + }); + } catch (e, stk) { + _logger?.warning('Failed to mark channels as delivered', e, stk); + } + } + + /// Cancels all pending delivery reports. + /// + /// Typically used when shutting down the reporter or permanently stopping + /// delivery reporting. + void cancel() { + _throttledMarkCandidatesAsDelivered.cancel(); + _deliveryCandidatesLock.synchronized(_deliveryCandidates.clear); + } +} diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index d08d92fe96..9f8261bfa1 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -6,6 +6,7 @@ import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/client/channel_delivery_reporter.dart'; import 'package:stream_chat/src/client/retry_policy.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/requests.dart'; @@ -27,6 +28,7 @@ import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/filter.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_delivery.dart'; import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/own_user.dart'; import 'package:stream_chat/src/core/models/poll.dart'; @@ -228,6 +230,15 @@ class StreamChatClient { StreamSubscription>? _connectionStatusSubscription; + /// Manages delivery receipt reporting for channel messages. + /// + /// Collects and batches delivery receipts to acknowledge message delivery + /// to senders across multiple channels. + late final channelDeliveryReporter = ChannelDeliveryReporter( + logger: detachedLogger('๐Ÿงพ'), + onMarkChannelsDelivered: markChannelsDelivered, + ); + final _eventController = PublishSubject(); /// Stream of [Event] coming from [_ws] connection @@ -776,6 +787,8 @@ class StreamChatClient { logger.info('Got ${res.channels.length} channels from api'); final updateData = _mapChannelStateToChannel(channels); + // Submit delivery report for the channels fetched in this query. + await channelDeliveryReporter.submitForDelivery(updateData.value); await chatPersistenceClient?.updateChannelQueries( filter, @@ -1661,6 +1674,29 @@ class StreamChatClient { /// Mark all channels for this user as read Future markAllRead() => _chatApi.channel.markAllRead(); + /// Sends delivery receipts for the latest messages in multiple channels. + /// + /// Useful when receiving messages through push notifications where only + /// channel IDs and message IDs are available, without full channel/message + /// objects. For in-app message delivery, use [channelDeliveryReporter] + /// which handles this automatically. + /// + /// ```dart + /// // From notification payload + /// final receipt = MessageDeliveryInfo( + /// channelCid: notificationData['channel_id'], + /// messageId: notificationData['message_id'], + /// ); + /// await client.markChannelsDelivered([receipt]); + /// ``` + /// + /// Accepts up to 100 channels per call. + Future markChannelsDelivered( + Iterable deliveries, + ) { + return _chatApi.channel.markChannelsDelivered([...deliveries]); + } + /// Send an event to a particular channel Future sendEvent( String channelId, @@ -2097,6 +2133,9 @@ class StreamChatClient { Future disconnectUser({bool flushChatPersistence = false}) async { logger.info('Disconnecting user : ${state.currentUser?.id}'); + // Cancelling delivery reporter. + channelDeliveryReporter.cancel(); + // closing web-socket connection closeConnection(); @@ -2235,10 +2274,12 @@ class ClientState { void _listenAllChannelsRead() { _eventsSubscription?.add( _client.on(EventType.notificationMarkRead).listen((event) { - if (event.cid == null) { - channels.forEach((key, value) { - value.state?.unreadCount = 0; - }); + // If a cid is provided, it means it's for a specific channel. + if (event.cid != null) return; + + // Update all channels' unread count to 0. + for (final channel in channels.values) { + channel.state?.unreadCount = 0; } }), ); @@ -2342,10 +2383,7 @@ class ClientState { /// Adds a list of channels to the current list of cached channels void addChannels(Map channelMap) { - final newChannels = { - ...channels, - ...channelMap, - }; + final newChannels = {...channels, ...channelMap}; channels = newChannels; } diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index f87b99d539..f09c764b52 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -8,6 +8,7 @@ import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/filter.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_delivery.dart'; /// Defines the api dedicated to channel operations class ChannelApi { @@ -84,10 +85,7 @@ class ChannelApi { /// Mark all channels for this user as read Future markAllRead() async { - final response = await _client.post( - '/channels/read', - data: {}, - ); + final response = await _client.post('/channels/read', data: {}); return EmptyResponse.fromJson(response.data); } @@ -395,4 +393,19 @@ class ChannelApi { ); return PartialUpdateMemberResponse.fromJson(response.data); } + + /// Sends delivery receipts for the latest messages in multiple channels. + /// + /// Accepts up to 100 channels per call. + Future markChannelsDelivered( + List deliveries, + ) async { + final response = await _client.post( + '/channels/delivered', + data: jsonEncode({ + 'latest_delivered_messages': deliveries, + }), + ); + return EmptyResponse.fromJson(response.data); + } } diff --git a/packages/stream_chat/lib/src/core/models/channel_config.dart b/packages/stream_chat/lib/src/core/models/channel_config.dart index 55ad2b2fbf..40c11630ec 100644 --- a/packages/stream_chat/lib/src/core/models/channel_config.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.dart @@ -27,6 +27,7 @@ class ChannelConfig { this.skipLastMsgUpdateForSystemMsgs = false, this.userMessageReminders = false, this.markMessagesPending = false, + this.deliveryEvents = false, }) : createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); @@ -95,6 +96,9 @@ class ChannelConfig { /// Whether pending messages are enabled for this channel. final bool markMessagesPending; + /// Whether delivery events are enabled for this channel. + final bool deliveryEvents; + /// Serialize to json Map toJson() => _$ChannelConfigToJson(this); } diff --git a/packages/stream_chat/lib/src/core/models/channel_config.g.dart b/packages/stream_chat/lib/src/core/models/channel_config.g.dart index ae1221b991..38b2d0d24f 100644 --- a/packages/stream_chat/lib/src/core/models/channel_config.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.g.dart @@ -35,6 +35,7 @@ ChannelConfig _$ChannelConfigFromJson(Map json) => json['skip_last_msg_update_for_system_msgs'] as bool? ?? false, userMessageReminders: json['user_message_reminders'] as bool? ?? false, markMessagesPending: json['mark_messages_pending'] as bool? ?? false, + deliveryEvents: json['delivery_events'] as bool? ?? false, ); Map _$ChannelConfigToJson(ChannelConfig instance) => @@ -59,4 +60,5 @@ Map _$ChannelConfigToJson(ChannelConfig instance) => instance.skipLastMsgUpdateForSystemMsgs, 'user_message_reminders': instance.userMessageReminders, 'mark_messages_pending': instance.markMessagesPending, + 'delivery_events': instance.deliveryEvents, }; diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index 5404e348b9..2fb7e84f42 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -358,6 +358,9 @@ extension type const ChannelCapability(String capability) implements String { /// Ability to receive read events. static const readEvents = ChannelCapability('read-events'); + /// Ability to receive delivery events. + static const deliveryEvents = ChannelCapability('delivery-events'); + /// Ability to receive connect events. static const connectEvents = ChannelCapability('connect-events'); diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 8e2733a3b4..57d2aaeec2 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -44,6 +44,8 @@ class Event { this.pushPreference, this.channelPushPreference, this.channelMessageCount, + this.lastDeliveredAt, + this.lastDeliveredMessageId, this.extraData = const {}, this.isLocal = true, }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); @@ -166,6 +168,12 @@ class Event { /// The total number of messages in the channel. final int? channelMessageCount; + /// The date of the last delivered message. + final DateTime? lastDeliveredAt; + + /// The id of the last delivered message. + final String? lastDeliveredMessageId; + /// Map of custom channel extraData final Map extraData; @@ -208,6 +216,8 @@ class Event { 'push_preference', 'channel_push_preference', 'channel_message_count', + 'last_delivered_at', + 'last_delivered_message_id', ]; /// Serialize to json @@ -252,6 +262,8 @@ class Event { PushPreference? pushPreference, ChannelPushPreference? channelPushPreference, int? channelMessageCount, + DateTime? lastDeliveredAt, + String? lastDeliveredMessageId, Map? extraData, }) => Event( @@ -291,6 +303,9 @@ class Event { channelPushPreference: channelPushPreference ?? this.channelPushPreference, channelMessageCount: channelMessageCount ?? this.channelMessageCount, + lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, + lastDeliveredMessageId: + lastDeliveredMessageId ?? this.lastDeliveredMessageId, isLocal: isLocal, extraData: extraData ?? this.extraData, ); @@ -323,3 +338,16 @@ enum AITypingState { @JsonValue('AI_STATE_GENERATING') generating, } + +/// Helper extension methods for [Event]. +extension EventExtension on Event { + /// Whether the event is from the given user. + bool isFromUser({String? userId}) { + if (userId == null) return false; + + final eventUserId = this.userId ?? user?.id; + if (eventUserId == null) return false; + + return eventUserId == userId; + } +} diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart index 66b5b5bd03..7bc820b33e 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -77,6 +77,10 @@ Event _$EventFromJson(Map json) => Event( : ChannelPushPreference.fromJson( json['channel_push_preference'] as Map), channelMessageCount: (json['channel_message_count'] as num?)?.toInt(), + lastDeliveredAt: json['last_delivered_at'] == null + ? null + : DateTime.parse(json['last_delivered_at'] as String), + lastDeliveredMessageId: json['last_delivered_message_id'] as String?, extraData: json['extra_data'] as Map? ?? const {}, isLocal: json['is_local'] as bool? ?? false, ); @@ -127,6 +131,10 @@ Map _$EventToJson(Event instance) => { 'channel_push_preference': value, if (instance.channelMessageCount case final value?) 'channel_message_count': value, + if (instance.lastDeliveredAt?.toIso8601String() case final value?) + 'last_delivered_at': value, + if (instance.lastDeliveredMessageId case final value?) + 'last_delivered_message_id': value, 'extra_data': instance.extraData, }; diff --git a/packages/stream_chat/lib/src/core/models/message_delivery.dart b/packages/stream_chat/lib/src/core/models/message_delivery.dart new file mode 100644 index 0000000000..e814a2fc16 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delivery.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'message_delivery.g.dart'; + +/// A delivery receipt for a message in a channel. +/// +/// Used to acknowledge that the current user has received a message, +/// notifying the sender that their message was delivered. +@JsonSerializable(createFactory: false) +class MessageDelivery { + /// Creates a delivery receipt for a message. + const MessageDelivery({ + required this.channelCid, + required this.messageId, + }); + + /// The channel identifier containing the message. + @JsonKey(name: 'cid') + final String channelCid; + + /// The identifier of the message received. + @JsonKey(name: 'id') + final String messageId; + + /// Converts this [MessageDelivery] to JSON. + Map toJson() => _$MessageDeliveryToJson(this); +} diff --git a/packages/stream_chat/lib/src/core/models/message_delivery.g.dart b/packages/stream_chat/lib/src/core/models/message_delivery.g.dart new file mode 100644 index 0000000000..7a4f1431b9 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delivery.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_delivery.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$MessageDeliveryToJson(MessageDelivery instance) => + { + 'cid': instance.channelCid, + 'id': instance.messageId, + }; diff --git a/packages/stream_chat/lib/src/core/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart index c8a07bc248..c685761937 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -206,4 +206,12 @@ extension PrivacySettingsExtension on OwnUser { return readIndicators.enabled; } + + /// Whether delivery receipts are enabled for the user. + bool get isDeliveryReceiptsEnabled { + final deliveryIndicators = privacySettings?.deliveryReceipts; + if (deliveryIndicators == null) return true; + + return deliveryIndicators.enabled; + } } diff --git a/packages/stream_chat/lib/src/core/models/privacy_settings.dart b/packages/stream_chat/lib/src/core/models/privacy_settings.dart index 1ff51f7069..e0388fd7ab 100644 --- a/packages/stream_chat/lib/src/core/models/privacy_settings.dart +++ b/packages/stream_chat/lib/src/core/models/privacy_settings.dart @@ -10,6 +10,7 @@ class PrivacySettings extends Equatable { const PrivacySettings({ this.typingIndicators, this.readReceipts, + this.deliveryReceipts, }); /// Create a new instance from json. @@ -23,11 +24,14 @@ class PrivacySettings extends Equatable { /// The settings for the read receipt events. final ReadReceipts? readReceipts; + /// The settings for the delivery receipt events. + final DeliveryReceipts? deliveryReceipts; + /// Serialize to json. Map toJson() => _$PrivacySettingsToJson(this); @override - List get props => [typingIndicators, readReceipts]; + List get props => [typingIndicators, readReceipts, deliveryReceipts]; } /// The settings for typing indicator events. @@ -80,3 +84,29 @@ class ReadReceipts extends Equatable { @override List get props => [enabled]; } + +/// The settings for the delivery receipt events. +@JsonSerializable(includeIfNull: false) +class DeliveryReceipts extends Equatable { + /// Create a new instance of [DeliveryReceipts]. + const DeliveryReceipts({ + this.enabled = true, + }); + + /// Create a new instance from json. + factory DeliveryReceipts.fromJson(Map json) { + return _$DeliveryReceiptsFromJson(json); + } + + /// Whether the delivery receipt events are enabled for the user. + /// + /// If False, the user delivery events will not be sent to other users, along + /// with the user's delivery state. + final bool enabled; + + /// Serialize to json. + Map toJson() => _$DeliveryReceiptsToJson(this); + + @override + List get props => [enabled]; +} diff --git a/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart b/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart index a4a0942b8c..e433675833 100644 --- a/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart +++ b/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart @@ -16,6 +16,10 @@ PrivacySettings _$PrivacySettingsFromJson(Map json) => ? null : ReadReceipts.fromJson( json['read_receipts'] as Map), + deliveryReceipts: json['delivery_receipts'] == null + ? null + : DeliveryReceipts.fromJson( + json['delivery_receipts'] as Map), ); Map _$PrivacySettingsToJson(PrivacySettings instance) => @@ -24,6 +28,8 @@ Map _$PrivacySettingsToJson(PrivacySettings instance) => 'typing_indicators': value, if (instance.readReceipts?.toJson() case final value?) 'read_receipts': value, + if (instance.deliveryReceipts?.toJson() case final value?) + 'delivery_receipts': value, }; TypingIndicators _$TypingIndicatorsFromJson(Map json) => @@ -44,3 +50,13 @@ Map _$ReadReceiptsToJson(ReadReceipts instance) => { 'enabled': instance.enabled, }; + +DeliveryReceipts _$DeliveryReceiptsFromJson(Map json) => + DeliveryReceipts( + enabled: json['enabled'] as bool? ?? true, + ); + +Map _$DeliveryReceiptsToJson(DeliveryReceipts instance) => + { + 'enabled': instance.enabled, + }; diff --git a/packages/stream_chat/lib/src/core/models/read.dart b/packages/stream_chat/lib/src/core/models/read.dart index c82ad093b3..165528946f 100644 --- a/packages/stream_chat/lib/src/core/models/read.dart +++ b/packages/stream_chat/lib/src/core/models/read.dart @@ -1,5 +1,7 @@ +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/user.dart'; part 'read.g.dart'; @@ -13,6 +15,8 @@ class Read extends Equatable { required this.user, this.lastReadMessageId, int? unreadMessages, + this.lastDeliveredAt, + this.lastDeliveredMessageId, }) : unreadMessages = unreadMessages ?? 0; /// Create a new instance from a json @@ -30,6 +34,12 @@ class Read extends Equatable { /// The id of the last read message final String? lastReadMessageId; + /// Date of the last delivered message + final DateTime? lastDeliveredAt; + + /// The id of the last delivered message + final String? lastDeliveredMessageId; + /// Serialize to json Map toJson() => _$ReadToJson(this); @@ -39,13 +49,33 @@ class Read extends Equatable { String? lastReadMessageId, User? user, int? unreadMessages, - }) => - Read( - lastRead: lastRead ?? this.lastRead, - lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, - user: user ?? this.user, - unreadMessages: unreadMessages ?? this.unreadMessages, - ); + DateTime? lastDeliveredAt, + String? lastDeliveredMessageId, + }) { + return Read( + lastRead: lastRead ?? this.lastRead, + lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, + user: user ?? this.user, + unreadMessages: unreadMessages ?? this.unreadMessages, + lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, + lastDeliveredMessageId: + lastDeliveredMessageId ?? this.lastDeliveredMessageId, + ); + } + + /// Creates a new [Read] which is the merge of this and [other]. + Read merge(Read? other) { + if (other == null) return this; + + return copyWith( + lastRead: other.lastRead, + lastReadMessageId: other.lastReadMessageId, + user: other.user, + unreadMessages: other.unreadMessages, + lastDeliveredAt: other.lastDeliveredAt, + lastDeliveredMessageId: other.lastDeliveredMessageId, + ); + } @override List get props => [ @@ -53,5 +83,65 @@ class Read extends Equatable { lastReadMessageId, user, unreadMessages, + lastDeliveredAt, + lastDeliveredMessageId, ]; } + +/// Helper extension methods for [Iterable]<[Read]>. +/// +/// Adds methods to easily query reads for specific users or messages. +extension ReadIterableExtension on Iterable { + /// Returns the [Read] for the given [userId], or null if not found. + Read? userReadOf({String? userId}) { + if (userId == null) return null; + return firstWhereOrNull((read) => read.user.id == 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}) { + final sender = message.user; + if (sender == null) return []; + + return where((read) { + if (read.user.id == sender.id) return false; + if (read.lastRead.isBefore(message.createdAt)) return false; + + return true; + }).toList(); + } + + /// Returns the list of [Read]s that have marked the given [message] as + /// delivered. + /// + /// The [Read] is considered to have received the message if: + /// - The read user is not the sender of the message. + /// - The read contains a non-null lastDeliveredAt that is after or equal to + /// the message's createdAt, OR the user has already read the message. + List deliveriesOf({required Message message}) { + final sender = message.user; + if (sender == null) return []; + + return where((read) { + if (read.user.id == sender.id) return false; + + // Early check if the message is already read by the user. + // + // This covers the case where lastDeliveredAt is null but the message + // has already been read. + final lastReadAt = read.lastRead; + if (!lastReadAt.isBefore(message.createdAt)) return true; + + final lastDeliveredAt = read.lastDeliveredAt; + if (lastDeliveredAt == null) return false; + + if (lastDeliveredAt.isBefore(message.createdAt)) return false; + + return true; + }).toList(); + } +} diff --git a/packages/stream_chat/lib/src/core/models/read.g.dart b/packages/stream_chat/lib/src/core/models/read.g.dart index 9fb04c43eb..3e9d03fb0e 100644 --- a/packages/stream_chat/lib/src/core/models/read.g.dart +++ b/packages/stream_chat/lib/src/core/models/read.g.dart @@ -11,6 +11,10 @@ Read _$ReadFromJson(Map json) => Read( user: User.fromJson(json['user'] as Map), lastReadMessageId: json['last_read_message_id'] as String?, unreadMessages: (json['unread_messages'] as num?)?.toInt(), + lastDeliveredAt: json['last_delivered_at'] == null + ? null + : DateTime.parse(json['last_delivered_at'] as String), + lastDeliveredMessageId: json['last_delivered_message_id'] as String?, ); Map _$ReadToJson(Read instance) => { @@ -18,4 +22,6 @@ Map _$ReadToJson(Read instance) => { 'user': instance.user.toJson(), 'unread_messages': instance.unreadMessages, 'last_read_message_id': instance.lastReadMessageId, + 'last_delivered_at': instance.lastDeliveredAt?.toIso8601String(), + 'last_delivered_message_id': instance.lastDeliveredMessageId, }; diff --git a/packages/stream_chat/lib/src/core/util/extension.dart b/packages/stream_chat/lib/src/core/util/extension.dart index 752889bfbb..9d409dacc3 100644 --- a/packages/stream_chat/lib/src/core/util/extension.dart +++ b/packages/stream_chat/lib/src/core/util/extension.dart @@ -47,6 +47,23 @@ extension StreamControllerX on StreamController { } } +/// Extension on [Completer] to safely complete with value or error. +extension CompleterX on Completer { + /// Safely completes the completer with the provided value. + /// Returns early if the completer is already completed. + void safeComplete([T? value]) { + if (isCompleted) return; + complete(value); + } + + /// Safely completes the completer with the provided error. + /// Returns early if the completer is already completed. + void safeCompleteError(Object error, [StackTrace? stackTrace]) { + if (isCompleted) return; + completeError(error, stackTrace); + } +} + /// Extension providing merge functionality for any iterable. extension IterableMergeExtension on Iterable { /// Merges this iterable with another iterable of the same type. diff --git a/packages/stream_chat/lib/src/core/util/message_rules.dart b/packages/stream_chat/lib/src/core/util/message_rules.dart new file mode 100644 index 0000000000..6db2cfbf31 --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/message_rules.dart @@ -0,0 +1,193 @@ +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/own_user.dart'; + +/// Provides validation rules for message operations. +/// +/// Determines whether messages can be sent, counted as unread, marked as +/// delivered, or update channel timestamps based on business rules. +class MessageRules { + const MessageRules._(); + + /// Whether the [message] is valid for upload (sending or updating). + /// + /// Returns `true` if the message has at least one of the following: + /// + /// * Non-empty text content + /// * At least one attachment + /// * A quoted message reference + /// * A poll + static bool canUpload(Message message) { + final hasText = message.text?.trim().isNotEmpty == true; + final hasAttachments = message.attachments.isNotEmpty; + final hasQuotedMessage = message.quotedMessageId != null; + final hasPoll = message.pollId != null; + + return hasText || hasAttachments || hasQuotedMessage || hasPoll; + } + + /// Whether the [message] can update the channel's last message timestamp. + /// + /// Returns `false` for error, shadowed, ephemeral, or restricted messages, + /// and system messages when the channel config skips them. + /// + /// See: https://github.com/GetStream/chat/blob/9245c2b3f7e679267d57ee510c60e93de051cb8e/types/channel.go#L1136-L1150 + static bool canUpdateChannelLastMessageAt( + Message message, + Channel channel, + ) { + if (message.isError) return false; + if (message.shadowed) return false; + if (message.isEphemeral) return false; + + final config = channel.state?.channelState.channel?.config; + if (message.isSystem && config?.skipLastMsgUpdateForSystemMsgs == true) { + return false; + } + + final currentUserId = channel.client.state.currentUser?.id; + if (currentUserId case final userId? when message.isNotVisibleTo(userId)) { + return false; + } + + return true; + } + + /// Whether the [message] can be counted as unread in the given [channel]. + /// + /// Returns `false` for the current user's own messages, messages from muted + /// users, silent/shadowed/ephemeral messages, thread-only replies, restricted + /// messages, and messages already read. Also returns `false` if the channel + /// is muted or doesn't support read events. + static bool canCountAsUnread( + Message message, + Channel channel, + ) { + // Don't count if the current user is not set. + final currentUser = channel.client.state.currentUser; + if (currentUser == null) return false; + + // Don't count if the user has disabled read receipts. + if (!currentUser.isReadReceiptsEnabled) return false; + + // Don't count if the channel doesn't support read receipts. + if (!channel.canUseReadReceipts) return false; + + // Don't count if the channel is muted. + if (channel.isMuted) return false; + + // Don't count if the message is silent, shadowed, or ephemeral. + if (message.silent) return false; + if (message.shadowed) return false; + if (message.isEphemeral) return false; + + // Don't count thread-only messages towards channel unread count. + if (message.parentId != null && message.showInChannel != true) { + return false; + } + + // Don't count if the message doesn't have a sender. + final messageUser = message.user; + if (messageUser == null) return false; + + // Don't count the current user's own messages. + if (messageUser.id == currentUser.id) return false; + + // Don't count restricted messages. + if (message.isNotVisibleTo(currentUser.id)) return false; + + // Don't count messages from muted users. + final isMuted = currentUser.mutes.any((it) { + return it.target.id == messageUser.id; + }); + if (isMuted) return false; + + final currentUserRead = channel.state?.currentUserRead; + if (currentUserRead == null) return true; + + final lastRead = currentUserRead.lastRead; + // Don't count messages at or before the last read time. + if (!message.createdAt.isAfter(lastRead)) return false; + + final lastReadMessageId = currentUserRead.lastReadMessageId; + // Don't count if this is the last read message. + if (lastReadMessageId case final id? when message.id == id) return false; + + return true; + } + + /// Whether the [message] can be marked as delivered in the given [channel]. + /// + /// Returns `false` if any of the following conditions are met: + /// + /// * The message is ephemeral + /// * The message is a thread reply not shown in the channel + /// * The message has no sender + /// * There is no current user + /// * The message was sent by the current user + /// * The message is restricted (not visible to the current user) + /// * The message was created before or at the last read time + /// * The message is the last read message (already marked as read) + /// * The message was created before or at the last delivered time + /// * The message is the last delivered message (already marked as delivered) + static bool canMarkAsDelivered( + Message message, + Channel channel, + ) { + // Don't deliver receipts if the current user is not set. + final currentUser = channel.client.state.currentUser; + if (currentUser == null) return false; + + // Don't deliver receipts if the user has disabled delivery receipts. + if (!currentUser.isDeliveryReceiptsEnabled) return false; + + // Don't deliver receipts if the channel doesn't support delivery receipts. + if (!channel.canUseDeliveryReceipts) return false; + + // Don't deliver receipts if the channel is muted. + if (channel.isMuted) return false; + + // Don't deliver receipts for ephemeral messages. + if (message.isEphemeral) return false; + + // Don't deliver receipts for thread-only messages. + if (message.parentId != null && message.showInChannel != true) { + return false; + } + + // Don't deliver receipts if the message doesn't have a sender. + final messageUser = message.user; + if (messageUser == null) return false; + + // Don't deliver receipts for the current user's own messages. + if (messageUser.id == currentUser.id) return false; + + // Don't deliver receipts for restricted messages. + if (message.isNotVisibleTo(currentUser.id)) return false; + + final currentUserRead = channel.state?.currentUserRead; + if (currentUserRead == null) return true; + + final lastRead = currentUserRead.lastRead; + // Don't deliver receipts for messages at or before the last read time. + if (!message.createdAt.isAfter(lastRead)) return false; + + final lastReadMessageId = currentUserRead.lastReadMessageId; + // Don't deliver receipts if this is the last read message. + if (lastReadMessageId case final id? when message.id == id) return false; + + final lastDelivered = currentUserRead.lastDeliveredAt; + // Don't deliver receipts for messages at or before the last delivered time. + if (lastDelivered case final last? when !message.createdAt.isAfter(last)) { + return false; + } + + final lastDeliveredMessageId = currentUserRead.lastDeliveredMessageId; + // Don't deliver receipts if this is the last delivered message. + if (lastDeliveredMessageId case final id? when message.id == id) { + return false; + } + + return true; + } +} diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index 63c3c3f8ef..12f0eb8f6f 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -177,4 +177,7 @@ class EventType { /// Local event sent when channel push notification preference is updated. static const String channelPushPreferenceUpdated = 'channel.push_preference.updated'; + + /// Event sent when a message is marked as delivered. + static const String messageDelivered = 'message.delivered'; } diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index aac89d222f..457d0c48a5 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -17,6 +17,7 @@ export 'package:rate_limiter/rate_limiter.dart'; export 'package:uuid/uuid.dart'; export 'src/client/channel.dart'; +export 'src/client/channel_delivery_reporter.dart'; export 'src/client/client.dart'; export 'src/client/key_stroke_handler.dart'; export 'src/client/retry_policy.dart'; @@ -45,6 +46,7 @@ export 'src/core/models/event.dart'; export 'src/core/models/filter.dart' show Filter; export 'src/core/models/member.dart'; export 'src/core/models/message.dart'; +export 'src/core/models/message_delivery.dart'; export 'src/core/models/message_reminder.dart'; export 'src/core/models/message_state.dart'; export 'src/core/models/moderation.dart'; @@ -66,6 +68,7 @@ export 'src/core/models/user.dart'; export 'src/core/models/user_block.dart'; export 'src/core/platform_detector/platform_detector.dart'; export 'src/core/util/extension.dart'; +export 'src/core/util/message_rules.dart'; export 'src/db/chat_persistence_client.dart'; export 'src/event_type.dart'; export 'src/permission_type.dart'; diff --git a/packages/stream_chat/test/fixtures/read.json b/packages/stream_chat/test/fixtures/read.json index b102fb9874..f337763e99 100644 --- a/packages/stream_chat/test/fixtures/read.json +++ b/packages/stream_chat/test/fixtures/read.json @@ -4,5 +4,7 @@ }, "last_read": "2020-01-28T22:17:30.966485504Z", "unread_messages": 10, - "last_read_message_id": "8cc1301d-2d47-4305-945a-cd8e19b736d6" + "last_read_message_id": "8cc1301d-2d47-4305-945a-cd8e19b736d6", + "last_delivered_at": "2020-01-28T22:17:30.966485504Z", + "last_delivered_message_id": "8cc1301d-2d47-4305-945a-cd8e19b736d6" } \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart new file mode 100644 index 0000000000..424e00cec9 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart @@ -0,0 +1,628 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; +import '../utils.dart'; + +void main() { + group('ChannelDeliveryReporter', () { + late StreamChatClient client; + late List capturedDeliveries; + late ChannelDeliveryReporter reporter; + + setUpAll(() { + registerFallbackValue( + const MessageDelivery( + channelCid: 'test:test', + messageId: 'test-message-id', + ), + ); + }); + + setUp(() { + client = _createMockClient(); + capturedDeliveries = []; + reporter = ChannelDeliveryReporter( + throttleDuration: const Duration(milliseconds: 100), + onMarkChannelsDelivered: (deliveries) async { + capturedDeliveries.addAll(deliveries); + }, + ); + }); + + tearDown(() { + reporter.cancel(); + }); + + group('submitForDelivery', () { + test('should submit channels with valid messages', () async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message1, + ); + + final channel2 = _createDeliverableChannel( + client, + cid: 'test:channel-2', + message: message2, + ); + + await reporter.submitForDelivery([channel1, channel2]); + await delay(150); + + expect(capturedDeliveries, hasLength(2)); + expect( + capturedDeliveries.any( + (d) => + d.channelCid == 'test:channel-1' && d.messageId == 'message-1', + ), + isTrue, + ); + expect( + capturedDeliveries.any( + (d) => + d.channelCid == 'test:channel-2' && d.messageId == 'message-2', + ), + isTrue, + ); + }); + + test('should skip channels without cid', () async { + final channel = Channel(client, 'test-type', null); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should skip channels without last message', () async { + final channel = _createChannel(client, cid: 'test:channel-1'); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should skip channels without delivery capability', () async { + final channel = _createChannelWithoutCapability( + client, + cid: 'test:channel-1', + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should skip messages from current user', () async { + final channel = _createChannelWithOwnMessage( + client, + cid: 'test:channel-1', + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should skip messages that are already read', () async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + client, + lastRead: DateTime(2023, 1, 2), + lastReadMessageId: message.id, + ), + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should skip messages that are already delivered', () async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + client, + lastDeliveredAt: DateTime(2023, 1, 2), + lastDeliveredMessageId: message.id, + ), + ); + + await reporter.submitForDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should update existing candidates with newer messages', () async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message1, + ); + await reporter.submitForDelivery([channel1]); + + final channel1Updated = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message2, + ); + await reporter.submitForDelivery([channel1Updated]); + + await delay(150); + + expect(capturedDeliveries, hasLength(1)); + expect(capturedDeliveries.first.channelCid, 'test:channel-1'); + expect(capturedDeliveries.first.messageId, 'message-2'); + }); + + test('should throttle multiple submit calls', () async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + final message3 = _createMessage((m) => m.copyWith(id: 'message-3')); + + final channel1 = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message1, + ); + final channel2 = _createDeliverableChannel( + client, + cid: 'test:channel-2', + message: message2, + ); + final channel3 = _createDeliverableChannel( + client, + cid: 'test:channel-3', + message: message3, + ); + + // Submit 3 different channels in quick succession + await reporter.submitForDelivery([channel1]); + await reporter.submitForDelivery([channel2]); + await reporter.submitForDelivery([channel3]); + + // All 3 should be batched into a single delivery call due to throttling + await delay(150); + + expect(capturedDeliveries, hasLength(3)); + }); + }); + + group('reconcileDelivery', () { + test('should remove candidates that are now read', () async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + client, + cid: 'test:channel-1', + lastMessage: message, + ); + await reporter.submitForDelivery([channel]); + + final channelRead = _createChannel( + client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + client, + lastRead: DateTime(2023, 1, 2), + lastReadMessageId: message.id, + ), + ); + await reporter.reconcileDelivery([channelRead]); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should remove candidates that are now delivered', () async { + final message = _createMessage( + (m) => m.copyWith( + id: 'message-1', + createdAt: DateTime(2023), + ), + ); + + final channel = _createChannel( + client, + cid: 'test:channel-1', + lastMessage: message, + ); + await reporter.submitForDelivery([channel]); + + final channelDelivered = _createChannel( + client, + cid: 'test:channel-1', + lastMessage: message, + currentUserRead: _createCurrentUserRead( + client, + lastDeliveredAt: DateTime(2023, 1, 2), + lastDeliveredMessageId: message.id, + ), + ); + await reporter.reconcileDelivery([channelDelivered]); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should keep candidates that still need delivery', () async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message, + ); + + await reporter.submitForDelivery([channel]); + await reporter.reconcileDelivery([channel]); + + await delay(150); + + expect(capturedDeliveries, hasLength(1)); + expect(capturedDeliveries.first.messageId, 'message-1'); + }); + + test('should handle channels not in candidates', () async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message, + ); + + await reporter.reconcileDelivery([channel]); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should handle channels without cid', () async { + final channel = Channel(client, 'test-type', null); + + await reporter.reconcileDelivery([channel]); + }); + }); + + group('cancelDelivery', () { + test('should remove channels from candidates', () async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message, + ); + + await reporter.submitForDelivery([channel]); + await reporter.cancelDelivery(['test:channel-1']); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should handle channels not in candidates', () async { + await reporter.cancelDelivery(['test:channel-1']); + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should cancel specific channels only', () async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message1, + ); + final channel2 = _createDeliverableChannel( + client, + cid: 'test:channel-2', + message: message2, + ); + + await reporter.submitForDelivery([channel1, channel2]); + await reporter.cancelDelivery(['test:channel-1']); + + await delay(150); + + expect(capturedDeliveries, hasLength(1)); + expect(capturedDeliveries.first.channelCid, 'test:channel-2'); + }); + }); + + group('Batching and throttling', () { + test('should batch deliveries with max 100 channels', () async { + final channels = List.generate( + 150, + (index) { + final message = _createMessage( + (m) => m.copyWith(id: 'message-$index'), + ); + return _createDeliverableChannel( + client, + cid: 'test:channel-$index', + message: message, + ); + }, + ); + + await reporter.submitForDelivery(channels); + await delay(150); + + expect(capturedDeliveries, hasLength(100)); + + await delay(150); + + expect(capturedDeliveries, hasLength(150)); + }); + + test( + 'should only deliver latest message when updated before throttle', + () async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message1, + ); + await reporter.submitForDelivery([channel1]); + + final channel1Updated = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message2, + ); + await reporter.submitForDelivery([channel1Updated]); + + await delay(150); + + expect(capturedDeliveries, hasLength(1)); + expect(capturedDeliveries[0].messageId, 'message-2'); + }, + ); + + test('should handle delivery errors gracefully', () async { + var shouldFail = true; + final errorReporter = ChannelDeliveryReporter( + throttleDuration: const Duration(milliseconds: 50), + onMarkChannelsDelivered: (deliveries) async { + if (shouldFail) { + shouldFail = false; + throw Exception('Network error'); + } + capturedDeliveries.addAll(deliveries); + }, + ); + + try { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message, + ); + + await errorReporter.submitForDelivery([channel]); + await delay(100); + + expect(capturedDeliveries, isEmpty); + + await errorReporter.submitForDelivery([channel]); + await delay(100); + + expect(capturedDeliveries, hasLength(1)); + } finally { + errorReporter.cancel(); + } + }); + }); + + group('cancel', () { + test('should clear all candidates', () async { + final message1 = _createMessage((m) => m.copyWith(id: 'message-1')); + final message2 = _createMessage((m) => m.copyWith(id: 'message-2')); + + final channel1 = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message1, + ); + final channel2 = _createDeliverableChannel( + client, + cid: 'test:channel-2', + message: message2, + ); + + await reporter.submitForDelivery([channel1, channel2]); + + reporter.cancel(); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + + test('should cancel pending throttled calls', () async { + final message = _createMessage((m) => m.copyWith(id: 'message-1')); + final channel = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: message, + ); + + await reporter.submitForDelivery([channel]); + + reporter.cancel(); + + await delay(150); + + expect(capturedDeliveries, isEmpty); + }); + }); + }); +} + +// region Test Helpers + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +StreamChatClient _createMockClient() { + final client = MockStreamChatClient(); + final clientState = FakeClientState( + currentUser: OwnUser(id: 'current-user-id'), + ); + + when(() => client.state).thenReturn(clientState); + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + return _createLogger(invocation.positionalArguments.first as String); + }); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when(() => client.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, __, ___) => false), + ); + + return client; +} + +Message _createMessage([Message Function(Message)? builder]) { + final baseMessage = Message( + id: 'default-id', + text: 'Test message', + user: User(id: 'other-user-id'), + createdAt: DateTime.now(), + ); + + return builder?.call(baseMessage) ?? baseMessage; +} + +Channel _createChannel( + StreamChatClient client, { + required String cid, + Message? lastMessage, + bool hasDeliveryCapability = true, + Read? currentUserRead, +}) { + final channelState = ChannelState( + channel: ChannelModel( + cid: cid, + config: ChannelConfig(deliveryEvents: hasDeliveryCapability), + ownCapabilities: [ + if (hasDeliveryCapability) ChannelCapability.deliveryEvents, + ], + ), + messages: [if (lastMessage != null) lastMessage], + read: [if (currentUserRead != null) currentUserRead], + ); + + return Channel.fromState(client, channelState); +} + +Channel _createDeliverableChannel( + StreamChatClient client, { + required String cid, + required Message message, +}) { + return _createChannel( + client, + cid: cid, + lastMessage: message, + ); +} + +Channel _createChannelWithoutCapability( + StreamChatClient client, { + required String cid, +}) { + return _createChannel( + client, + cid: cid, + lastMessage: _createMessage(), + hasDeliveryCapability: false, + ); +} + +Channel _createChannelWithOwnMessage( + StreamChatClient client, { + required String cid, +}) { + final currentUser = client.state.currentUser!; + return _createChannel( + client, + cid: cid, + lastMessage: _createMessage( + (m) => m.copyWith(user: currentUser), + ), + ); +} + +Read _createCurrentUserRead( + StreamChatClient client, { + DateTime? lastRead, + String? lastReadMessageId, + DateTime? lastDeliveredAt, + String? lastDeliveredMessageId, + int? unreadMessages, +}) { + final currentUser = client.state.currentUser!; + return Read( + user: currentUser, + lastRead: lastRead ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, + unreadMessages: unreadMessages, + ); +} + +// endregion diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index b3f1f2e664..a22f1195d2 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: lines_longer_than_80_chars, cascade_invocations, deprecated_member_use_from_same_package +// 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'; @@ -201,6 +201,11 @@ void main() { // client logger when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + // mock channel delivery reporter + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); }); // Setting up a initialized channel @@ -3138,6 +3143,63 @@ void main() { ), ).called(1); }); + + test( + 'should submit for delivery when querying latest messages (no pagination)', + () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + // Query without pagination params (fetching latest messages) + await channel.query(); + + // Verify submitForDelivery was called + verify( + () => client.channelDeliveryReporter.submitForDelivery([channel]), + ).called(1); + }, + ); + + test( + 'should NOT submit for delivery when querying with pagination (older messages)', + () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + // Query with pagination params (fetching older messages) + await channel.query( + messagesPagination: const PaginationParams( + limit: 20, + lessThan: 'some-message-id', + ), + ); + + // Verify submitForDelivery was NOT called + verifyNever( + () => client.channelDeliveryReporter.submitForDelivery([channel]), + ); + }, + ); }); test('`.queryMembers`', () async { @@ -3532,6 +3594,11 @@ void main() { // client logger when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + // mock channel delivery reporter + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); }); group( @@ -3918,6 +3985,28 @@ void main() { }, ); }); + + 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); + }, + ); }, ); @@ -4257,56 +4346,8 @@ void main() { expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); }); - test('should update read state on notification mark read 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 read notification event - final markReadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkRead, - 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 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 notification mark read', + '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'); @@ -4318,7 +4359,7 @@ void main() { // Create mark read notification event final markReadEvent = Event( cid: channel.cid, - type: EventType.notificationMarkRead, + type: EventType.messageRead, user: currentUser, createdAt: DateTime(2022), unreadMessages: 0, @@ -4415,6 +4456,290 @@ void main() { 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 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', () { @@ -5093,6 +5418,291 @@ 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)), + ), + ], + ), + ); + }); + }); + group('ChannelCapabilityCheck', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; @@ -5397,6 +6007,11 @@ void main() { // 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', () { diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index bd7b9b455f..8ecf738235 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2917,6 +2917,28 @@ void main() { verifyNoMoreInteractions(api.channel); }); + test('`.markChannelsDelivered`', () async { + final deliveries = [ + const MessageDelivery( + channelCid: 'messaging:test-channel-1', + messageId: 'test-message-id-1', + ), + const MessageDelivery( + channelCid: 'messaging:test-channel-2', + messageId: 'test-message-id-2', + ), + ]; + + when(() => api.channel.markChannelsDelivered(deliveries)) + .thenAnswer((_) async => EmptyResponse()); + + final res = await client.markChannelsDelivered(deliveries); + expect(res, isNotNull); + + verify(() => api.channel.markChannelsDelivered(deliveries)).called(1); + verifyNoMoreInteractions(api.channel); + }); + test('`.sendEvent`', () async { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index 4b81d62873..2f05c98abc 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -806,4 +806,39 @@ void main() { })).called(1); verifyNoMoreInteractions(client); }); + + test('markChannelsDelivered', () async { + const path = '/channels/delivered'; + + final deliveries = [ + const MessageDelivery( + channelCid: 'messaging:test-channel-1', + messageId: 'test-message-id-1', + ), + const MessageDelivery( + channelCid: 'messaging:test-channel-2', + messageId: 'test-message-id-2', + ), + ]; + + when(() => client.post( + path, + data: any(named: 'data'), + )).thenAnswer((_) async => successResponse( + path, + data: {}, + )); + + final res = await channelApi.markChannelsDelivered(deliveries); + + expect(res, isNotNull); + + verify(() => client.post( + path, + data: jsonEncode({ + 'latest_delivered_messages': deliveries, + }), + )).called(1); + verifyNoMoreInteractions(client); + }); } diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index caed483c03..10f864ebd8 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -644,5 +644,48 @@ void main() { expect(user.isReadReceiptsEnabled, true); }, ); + + test('isDeliveryReceiptsEnabled should return true when null', () { + final user = OwnUser(id: 'test-user'); + + expect(user.isDeliveryReceiptsEnabled, true); + }); + + test('isDeliveryReceiptsEnabled should return true when enabled', () { + final user = OwnUser( + id: 'test-user', + privacySettings: const PrivacySettings( + deliveryReceipts: DeliveryReceipts(enabled: true), + ), + ); + + expect(user.isDeliveryReceiptsEnabled, true); + }); + + test('isDeliveryReceiptsEnabled should return false when disabled', () { + final user = OwnUser( + id: 'test-user', + privacySettings: const PrivacySettings( + deliveryReceipts: DeliveryReceipts(enabled: false), + ), + ); + + expect(user.isDeliveryReceiptsEnabled, false); + }); + + test( + 'isDeliveryReceiptsEnabled should return true when privacy settings ' + 'exists but delivery receipts is null', + () { + final user = OwnUser( + id: 'test-user', + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), + ); + + expect(user.isDeliveryReceiptsEnabled, true); + }, + ); }); } diff --git a/packages/stream_chat/test/src/core/models/privacy_settings_test.dart b/packages/stream_chat/test/src/core/models/privacy_settings_test.dart index dbf44d77b2..449ed76143 100644 --- a/packages/stream_chat/test/src/core/models/privacy_settings_test.dart +++ b/packages/stream_chat/test/src/core/models/privacy_settings_test.dart @@ -9,6 +9,7 @@ void main() { final json = { 'typing_indicators': {'enabled': false}, 'read_receipts': {'enabled': false}, + 'delivery_receipts': {'enabled': false}, }; final privacySettings = PrivacySettings.fromJson(json); @@ -17,6 +18,8 @@ void main() { expect(privacySettings.typingIndicators?.enabled, false); expect(privacySettings.readReceipts, isNotNull); expect(privacySettings.readReceipts?.enabled, false); + expect(privacySettings.deliveryReceipts, isNotNull); + expect(privacySettings.deliveryReceipts?.enabled, false); }); test('should parse json correctly with null fields', () { @@ -26,6 +29,7 @@ void main() { expect(privacySettings.typingIndicators, isNull); expect(privacySettings.readReceipts, isNull); + expect(privacySettings.deliveryReceipts, isNull); }); test('should parse json correctly with partial fields', () { @@ -38,22 +42,26 @@ void main() { expect(privacySettings.typingIndicators, isNotNull); expect(privacySettings.typingIndicators?.enabled, true); expect(privacySettings.readReceipts, isNull); + expect(privacySettings.deliveryReceipts, isNull); }); test('equality should work correctly', () { const privacySettings1 = PrivacySettings( typingIndicators: TypingIndicators(enabled: false), readReceipts: ReadReceipts(enabled: false), + deliveryReceipts: DeliveryReceipts(enabled: false), ); const privacySettings2 = PrivacySettings( typingIndicators: TypingIndicators(enabled: false), readReceipts: ReadReceipts(enabled: false), + deliveryReceipts: DeliveryReceipts(enabled: false), ); const privacySettings3 = PrivacySettings( typingIndicators: TypingIndicators(enabled: true), readReceipts: ReadReceipts(enabled: false), + deliveryReceipts: DeliveryReceipts(enabled: false), ); expect(privacySettings1, equals(privacySettings2)); @@ -124,4 +132,43 @@ void main() { expect(settings1, isNot(equals(settings3))); }); }); + + group('DeliveryReceiptsPrivacySettings', () { + test('should have enabled as true by default', () { + const settings = DeliveryReceipts(); + expect(settings.enabled, true); + }); + + test('should parse json correctly', () { + final json = {'enabled': false}; + + final settings = DeliveryReceipts.fromJson(json); + + expect(settings.enabled, false); + }); + + test('should parse json with enabled as true', () { + final json = {'enabled': true}; + + final settings = DeliveryReceipts.fromJson(json); + + expect(settings.enabled, true); + }); + + test('equality should work correctly', () { + const settings1 = DeliveryReceipts(enabled: true); + const settings2 = DeliveryReceipts(enabled: true); + const settings3 = DeliveryReceipts(enabled: false); + + expect(settings1, equals(settings2)); + expect(settings1, isNot(equals(settings3))); + }); + + test('toJson should serialize correctly', () { + const settings = DeliveryReceipts(enabled: false); + final json = settings.toJson(); + + expect(json, {'enabled': false}); + }); + }); } diff --git a/packages/stream_chat/test/src/core/models/read_test.dart b/packages/stream_chat/test/src/core/models/read_test.dart index aee64b6dbb..197fa3f161 100644 --- a/packages/stream_chat/test/src/core/models/read_test.dart +++ b/packages/stream_chat/test/src/core/models/read_test.dart @@ -12,6 +12,14 @@ void main() { expect(read.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); expect(read.unreadMessages, 10); expect(read.lastReadMessageId, '8cc1301d-2d47-4305-945a-cd8e19b736d6'); + expect( + read.lastDeliveredAt, + DateTime.parse('2020-01-28T22:17:30.966485504Z'), + ); + expect( + read.lastDeliveredMessageId, + '8cc1301d-2d47-4305-945a-cd8e19b736d6', + ); }); test('should serialize to json correctly', () { @@ -20,6 +28,8 @@ void main() { user: User(id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'), unreadMessages: 10, lastReadMessageId: '8cc1301d-2d47-4305-945a-cd8e19b736d6', + lastDeliveredAt: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + lastDeliveredMessageId: '8cc1301d-2d47-4305-945a-cd8e19b736d6', ); expect(read.toJson(), { @@ -32,6 +42,8 @@ void main() { 'last_read': '2020-01-28T22:17:30.966485Z', 'unread_messages': 10, 'last_read_message_id': '8cc1301d-2d47-4305-945a-cd8e19b736d6', + 'last_delivered_at': '2020-01-28T22:17:30.966485Z', + 'last_delivered_message_id': '8cc1301d-2d47-4305-945a-cd8e19b736d6', }); }); @@ -51,6 +63,8 @@ void main() { lastRead: DateTime.parse('2021-01-28T22:17:30.966485504Z'), unreadMessages: 2, lastReadMessageId: 'last_test', + lastDeliveredAt: DateTime.parse('2021-01-28T22:17:30.966485504Z'), + lastDeliveredMessageId: 'last_delivered_test', ); expect( @@ -60,6 +74,210 @@ void main() { expect(newRead.user.id, 'test'); expect(newRead.unreadMessages, 2); expect(newRead.lastReadMessageId, 'last_test'); + expect( + newRead.lastDeliveredAt, + DateTime.parse('2021-01-28T22:17:30.966485504Z'), + ); + expect(newRead.lastDeliveredMessageId, 'last_delivered_test'); + }); + + test('merge with null should return the same instance', () { + final read = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastReadMessageId: 'message-1', + lastDeliveredAt: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + lastDeliveredMessageId: 'delivered-1', + ); + + final merged = read.merge(null); + + expect(merged, same(read)); + }); + + test('merge should override all fields with other read', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastReadMessageId: 'message-1', + lastDeliveredAt: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + lastDeliveredMessageId: 'delivered-1', + ); + + final read2 = Read( + lastRead: DateTime.parse('2021-05-15T10:30:00.000000Z'), + user: User(id: 'user-2'), + unreadMessages: 5, + lastReadMessageId: 'message-2', + lastDeliveredAt: DateTime.parse('2021-05-15T10:30:00.000000Z'), + lastDeliveredMessageId: 'delivered-2', + ); + + final merged = read1.merge(read2); + + expect(merged.lastRead, read2.lastRead); + expect(merged.user.id, read2.user.id); + expect(merged.unreadMessages, read2.unreadMessages); + expect(merged.lastReadMessageId, read2.lastReadMessageId); + expect(merged.lastDeliveredAt, read2.lastDeliveredAt); + expect(merged.lastDeliveredMessageId, read2.lastDeliveredMessageId); + }); + + test('merge should handle null optional fields', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastReadMessageId: 'message-1', + lastDeliveredAt: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + lastDeliveredMessageId: 'delivered-1', + ); + + final read2 = Read( + lastRead: DateTime.parse('2021-05-15T10:30:00.000000Z'), + user: User(id: 'user-2'), + unreadMessages: 0, + ); + + final merged = read1.merge(read2); + + expect(merged.lastRead, read2.lastRead); + expect(merged.user.id, read2.user.id); + expect(merged.unreadMessages, 0); + // When merging, null values in read2 should preserve read1's values + expect(merged.lastReadMessageId, read1.lastReadMessageId); + expect(merged.lastDeliveredAt, read1.lastDeliveredAt); + expect(merged.lastDeliveredMessageId, read1.lastDeliveredMessageId); + }); + + test('equality should return true for identical reads', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastReadMessageId: 'message-1', + lastDeliveredAt: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + lastDeliveredMessageId: 'delivered-1', + ); + + final read2 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastReadMessageId: 'message-1', + lastDeliveredAt: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + lastDeliveredMessageId: 'delivered-1', + ); + + expect(read1, equals(read2)); + expect(read1.hashCode, equals(read2.hashCode)); }); + + test('equality should return false for different lastRead', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + ); + + final read2 = Read( + lastRead: DateTime.parse('2021-05-15T10:30:00.000000Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + ); + + expect(read1, isNot(equals(read2))); + }); + + test('equality should return false for different user', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + ); + + final read2 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-2'), + unreadMessages: 10, + ); + + expect(read1, isNot(equals(read2))); + }); + + test('equality should return false for different unreadMessages', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + ); + + final read2 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 5, + ); + + expect(read1, isNot(equals(read2))); + }); + + test('equality should return false for different lastReadMessageId', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastReadMessageId: 'message-1', + ); + + final read2 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastReadMessageId: 'message-2', + ); + + expect(read1, isNot(equals(read2))); + }); + + test('equality should return false for different lastDeliveredAt', () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastDeliveredAt: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + ); + + final read2 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastDeliveredAt: DateTime.parse('2021-05-15T10:30:00.000000Z'), + ); + + expect(read1, isNot(equals(read2))); + }); + + test( + 'equality should return false for different lastDeliveredMessageId', + () { + final read1 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastDeliveredMessageId: 'delivered-1', + ); + + final read2 = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User(id: 'user-1'), + unreadMessages: 10, + lastDeliveredMessageId: 'delivered-2', + ); + + expect(read1, isNot(equals(read2))); + }, + ); }); } diff --git a/packages/stream_chat/test/src/core/util/message_rules_test.dart b/packages/stream_chat/test/src/core/util/message_rules_test.dart new file mode 100644 index 0000000000..06f0d3ac62 --- /dev/null +++ b/packages/stream_chat/test/src/core/util/message_rules_test.dart @@ -0,0 +1,714 @@ +// ignore_for_file: 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('MessageRules', () { + late StreamChatClient client; + + setUp(() { + final currentUser = OwnUser(id: 'current-user-id'); + client = _createMockClient(currentUser: currentUser); + }); + + group('canUpload', () { + test('should return true for message with text', () { + final message = Message(text: 'Hello'); + + expect(MessageRules.canUpload(message), isTrue); + }); + + test('should return false for message with empty text', () { + final message = Message(text: ''); + + expect(MessageRules.canUpload(message), isFalse); + }); + + test('should return false for message with whitespace only', () { + final message = Message(text: ' '); + + expect(MessageRules.canUpload(message), isFalse); + }); + + test('should return true for message with attachments', () { + final message = Message( + attachments: [ + Attachment(type: 'image'), + ], + ); + + expect(MessageRules.canUpload(message), isTrue); + }); + + test('should return true for message with quoted message', () { + final message = Message(quotedMessageId: 'quoted-message-id'); + + expect(MessageRules.canUpload(message), isTrue); + }); + + test('should return true for message with poll', () { + final message = Message(pollId: 'poll-id'); + + expect(MessageRules.canUpload(message), isTrue); + }); + + test('should return false for empty message', () { + final message = Message(); + + expect(MessageRules.canUpload(message), isFalse); + }); + + test('should return true for message with multiple valid fields', () { + final message = Message( + text: 'Hello', + attachments: [Attachment(type: 'image')], + ); + + expect(MessageRules.canUpload(message), isTrue); + }); + }); + + group('canUpdateChannelLastMessageAt', () { + late Channel channel; + + setUp(() { + channel = _createChannel(client); + }); + + test('should return true for valid message', () { + final message = _createMessage(); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isTrue, + ); + }); + + test('should return false for error messages', () { + final message = _createMessage( + (m) => m.copyWith(type: MessageType.error), + ); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isFalse, + ); + }); + + test('should return false for shadowed messages', () { + final message = _createMessage( + (m) => m.copyWith(shadowed: true), + ); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isFalse, + ); + }); + + test('should return false for ephemeral messages', () { + final message = _createMessage( + (m) => m.copyWith(type: MessageType.ephemeral), + ); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isFalse, + ); + }); + + test( + 'should return false for system messages when config skips them', + () { + final channel = _createChannel( + client, + skipLastMsgUpdateForSystemMsgs: true, + ); + + final message = _createMessage( + (m) => m.copyWith(type: MessageType.system), + ); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isFalse, + ); + }, + ); + + test( + 'should return true for system messages when config allows them', + () { + final channel = _createChannel( + client, + skipLastMsgUpdateForSystemMsgs: false, + ); + + final message = _createMessage( + (m) => m.copyWith(type: MessageType.system), + ); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isTrue, + ); + }, + ); + + test('should return false for restricted messages', () { + final message = _createMessage( + (m) => m.copyWith( + restrictedVisibility: [ + 'other-user-id', // Not visible to current user + ], + ), + ); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isFalse, + ); + }); + + test('should return true for message visible to current user', () { + final currentUserId = client.state.currentUser!.id; + + final message = _createMessage( + (m) => m.copyWith( + restrictedVisibility: [ + currentUserId, // Visible to current user + 'other-user-id', + ], + ), + ); + + expect( + MessageRules.canUpdateChannelLastMessageAt(message, channel), + isTrue, + ); + }); + }); + + group('canCountAsUnread', () { + late Channel channel; + + setUp(() { + channel = _createChannel(client, hasReadEvents: true); + }); + + test('should return true for valid unread message', () { + final message = _createMessage(); + + expect(MessageRules.canCountAsUnread(message, channel), isTrue); + }); + + test('should return false when user disabled read receipts', () { + final message = _createMessage(); + final originalUser = client.state.currentUser; + + final userWithDisabledReceipts = originalUser?.copyWith( + privacySettings: const PrivacySettings( + readReceipts: ReadReceipts(enabled: false), + ), + ); + + client.state.updateUser(userWithDisabledReceipts); + addTearDown(() => client.state.updateUser(originalUser)); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false when channel lacks read capability', () { + final message = _createMessage(); + final channel = _createChannel(client, hasReadEvents: false); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false when channel is muted', () { + final message = _createMessage(); + final originalUser = client.state.currentUser; + + final userWithMutedChannel = originalUser?.copyWith( + channelMutes: [ + ChannelMute( + user: client.state.currentUser!, + channel: ChannelModel(cid: channel.cid), + createdAt: DateTime(2023, 1, 1), + updatedAt: DateTime(2023, 1, 1), + ), + ], + ); + + client.state.updateUser(userWithMutedChannel); + addTearDown(() => client.state.updateUser(originalUser)); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false for silent messages', () { + final message = _createMessage((m) => m.copyWith(silent: true)); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false for shadowed messages', () { + final message = _createMessage((m) => m.copyWith(shadowed: true)); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false for ephemeral messages', () { + final message = _createMessage( + (m) => m.copyWith(type: MessageType.ephemeral), + ); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false for thread-only messages', () { + final message = _createMessage( + (m) => m.copyWith( + parentId: 'parent-id', + showInChannel: false, + ), + ); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return true for thread messages shown in channel', () { + final message = _createMessage( + (m) => m.copyWith( + parentId: 'parent-id', + showInChannel: true, + ), + ); + + expect(MessageRules.canCountAsUnread(message, channel), isTrue); + }); + + test('should return false when message has no sender', () { + final messageWithoutUser = Message( + id: 'message-id', + text: 'Test message', + user: null, + createdAt: DateTime(2023, 1, 1), + ); + + expect( + MessageRules.canCountAsUnread(messageWithoutUser, channel), + isFalse, + ); + }); + + test('should return false for current user own messages', () { + final currentUserId = client.state.currentUser!.id; + + final message = _createMessage( + (m) => m.copyWith( + user: User(id: currentUserId), // Message from current user + ), + ); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false for restricted messages', () { + final message = _createMessage( + (m) => m.copyWith( + restrictedVisibility: [ + 'other-user-id', // Not visible to current user + ], + ), + ); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false for messages from muted users', () { + final mutedUser = User(id: 'muted-user-id'); + final originalUser = client.state.currentUser; + + final userWithMutedUser = originalUser?.copyWith( + mutes: [ + Mute( + user: originalUser, // Current user did the muting + target: mutedUser, // This is the user who was muted + createdAt: DateTime(2023, 1, 1), + updatedAt: DateTime(2023, 1, 1), + ), + ], + ); + + client.state.updateUser(userWithMutedUser); + addTearDown(() => client.state.updateUser(originalUser)); + + final message = _createMessage( + (m) => m.copyWith( + user: mutedUser, // Message from muted user + ), + ); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return true when no read state exists', () { + final message = _createMessage(); + + expect(MessageRules.canCountAsUnread(message, channel), isTrue); + }); + + test('should return false when message is already read', () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasReadEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + // After message + lastRead: message.createdAt.add(const Duration(days: 1)), + lastReadMessageId: 'other-message-id', + ), + ], + ); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return false when message is the last read message', () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasReadEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + lastRead: message.createdAt, // Same as message + lastReadMessageId: message.id, + ), + ], + ); + + expect(MessageRules.canCountAsUnread(message, channel), isFalse); + }); + + test('should return true for message after last read', () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasReadEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + // Before message + lastRead: message.createdAt.subtract(const Duration(days: 1)), + lastReadMessageId: 'other-message-id', + ), + ], + ); + + expect(MessageRules.canCountAsUnread(message, channel), isTrue); + }); + }); + + group('canMarkAsDelivered', () { + late Channel channel; + + setUp(() { + channel = _createChannel(client, hasDeliveryEvents: true); + }); + + test('should return true for valid deliverable message', () { + final message = _createMessage(); + + expect(MessageRules.canMarkAsDelivered(message, channel), isTrue); + }); + + test('should return false when user disabled delivery receipts', () { + final message = _createMessage(); + final originalUser = client.state.currentUser; + + final userWithDisabledReceipts = originalUser?.copyWith( + privacySettings: const PrivacySettings( + deliveryReceipts: DeliveryReceipts(enabled: false), + ), + ); + + client.state.updateUser(userWithDisabledReceipts); + addTearDown(() => client.state.updateUser(originalUser)); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return false when channel lacks delivery capability', () { + final message = _createMessage(); + final channel = _createChannel(client, hasDeliveryEvents: false); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return false when channel is muted', () { + final message = _createMessage(); + final originalUser = client.state.currentUser; + + final userWithMutedChannel = originalUser?.copyWith( + channelMutes: [ + ChannelMute( + user: client.state.currentUser!, + channel: ChannelModel(cid: 'test:channel-1'), + createdAt: DateTime(2023, 1, 1), + updatedAt: DateTime(2023, 1, 1), + ), + ], + ); + + client.state.updateUser(userWithMutedChannel); + addTearDown(() => client.state.updateUser(originalUser)); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return false for ephemeral messages', () { + final message = _createMessage( + (m) => m.copyWith(type: MessageType.ephemeral), + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return false for thread-only messages', () { + final message = _createMessage( + (m) => m.copyWith(parentId: 'parent-id', showInChannel: false), + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return true for thread messages shown in channel', () { + final message = _createMessage( + (m) => m.copyWith(parentId: 'parent-id', showInChannel: true), + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isTrue); + }); + + test('should return false when message has no sender', () { + final messageWithoutUser = Message( + id: 'message-id', + text: 'Test message', + user: null, + createdAt: DateTime(2023, 1, 1), + ); + + expect( + MessageRules.canMarkAsDelivered(messageWithoutUser, channel), + isFalse, + ); + }); + + test('should return false for current user own messages', () { + final currentUserId = client.state.currentUser!.id; + + final message = _createMessage( + (m) => m.copyWith( + user: User(id: currentUserId), // Message from current user + ), + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return false for restricted messages', () { + final message = _createMessage( + (m) => m.copyWith( + restrictedVisibility: [ + 'other-user-id', // Not visible to current user + ], + ), + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return true when no read state exists', () { + final message = _createMessage(); + + expect(MessageRules.canMarkAsDelivered(message, channel), isTrue); + }); + + test('should return false when message is already read', () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasDeliveryEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + // After message + lastRead: message.createdAt.add(const Duration(days: 1)), + lastReadMessageId: 'other-message-id', + ), + ], + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return false when message is the last read message', () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasDeliveryEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + lastRead: message.createdAt, // Same as message + lastReadMessageId: message.id, + ), + ], + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test('should return false when message is already delivered', () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasDeliveryEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + lastRead: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + // After message + lastDeliveredAt: message.createdAt.add(const Duration(days: 1)), + lastDeliveredMessageId: 'other-message-id', + ), + ], + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }); + + test( + 'should return false when message is the last delivered message', + () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasDeliveryEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + lastRead: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + lastDeliveredAt: message.createdAt, // Same as message + lastDeliveredMessageId: message.id, + ), + ], + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isFalse); + }, + ); + + test( + 'should return true for message between last delivered and last read', + () { + final message = _createMessage(); + final channel = _createChannel( + client, + hasDeliveryEvents: true, + reads: [ + Read( + user: client.state.currentUser!, + // Before message + lastRead: message.createdAt.subtract(const Duration(days: 1)), + // Before message and lastRead + lastDeliveredAt: message.createdAt.subtract( + const Duration(days: 2), + ), + ), + ], + ); + + expect(MessageRules.canMarkAsDelivered(message, channel), isTrue); + }, + ); + }); + }); +} + +// region Test Helpers + +Logger _createLogger(String name) { + final logger = Logger.detached(name)..level = Level.ALL; + logger.onRecord.listen(print); + return logger; +} + +StreamChatClient _createMockClient({OwnUser? currentUser}) { + final client = MockStreamChatClient(); + final clientState = FakeClientState(currentUser: currentUser); + + when(() => client.state).thenReturn(clientState); + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + return _createLogger(invocation.positionalArguments.first as String); + }); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + when(() => client.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, __, ___) => false), + ); + + return client; +} + +Message _createMessage([Message Function(Message)? builder]) { + final baseMessage = Message( + id: 'message-id', + text: 'Test message', + user: User(id: 'other-user-id'), + createdAt: DateTime(2023, 1, 1), + ); + + return builder?.call(baseMessage) ?? baseMessage; +} + +Channel _createChannel( + StreamChatClient client, { + String cid = 'test:channel-1', + Message? lastMessage, + bool hasReadEvents = false, + bool hasDeliveryEvents = false, + bool skipLastMsgUpdateForSystemMsgs = false, + List? capabilities, + List? reads, +}) { + final channelState = ChannelState( + channel: ChannelModel( + cid: cid, + config: ChannelConfig( + readEvents: hasReadEvents, + deliveryEvents: hasDeliveryEvents, + skipLastMsgUpdateForSystemMsgs: skipLastMsgUpdateForSystemMsgs, + ), + ownCapabilities: [ + ...?capabilities, + if (hasReadEvents) ChannelCapability.readEvents, + if (hasDeliveryEvents) ChannelCapability.deliveryEvents, + ], + ), + messages: [if (lastMessage != null) lastMessage], + read: [...?reads], + ); + + return Channel.fromState(client, channelState); +} + +// endregion diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 26e000e13e..5aed398bce 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -3,6 +3,7 @@ import 'package:logging/logging.dart'; import 'package:mocktail/mocktail.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/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'; import 'package:stream_chat/src/core/api/channel_api.dart'; @@ -96,6 +97,13 @@ class MockStreamChatClient extends Mock implements StreamChatClient { @override bool get persistenceEnabled => false; + ChannelDeliveryReporter? _deliveryReporter; + + @override + ChannelDeliveryReporter get channelDeliveryReporter { + return _deliveryReporter ??= MockChannelDeliveryReporter(); + } + @override Stream get eventStream => _eventController.stream; final _eventController = PublishSubject(); @@ -165,3 +173,6 @@ class MockRetryQueueChannel extends Mock implements Channel { } class MockWebSocket extends Mock implements WebSocket {} + +class MockChannelDeliveryReporter extends Mock + implements ChannelDeliveryReporter {} diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 88a3fe5b5f..fa6110a073 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -2,6 +2,8 @@ โœ… Added +- Added delivered status to `SendingIndicator` (double grey check for delivered, double accentPrimary check for read). +- Added `isMessageDelivered` parameter to `SendingIndicator` widget. - Added `MessagePreviewFormatter` interface and `StreamMessagePreviewFormatter` implementation for customizing message preview text formatting in channel lists and draft messages. - Added `messagePreviewFormatter` property to `StreamChatConfigurationData` for global customization diff --git a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart index f5b1960b97..5951336615 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart @@ -11,41 +11,59 @@ class StreamSendingIndicator extends StatelessWidget { super.key, required this.message, this.isMessageRead = false, + this.isMessageDelivered = false, this.size = 12, }); - /// Message for sending indicator + /// The message whose sending status is to be shown. final Message message; - /// Flag if message is read + /// Whether the message is read by the recipient. final bool isMessageRead; - /// Size for message + /// Whether the message is delivered to the recipient. + final bool isMessageDelivered; + + /// The size of the indicator icon. final double? size; @override Widget build(BuildContext context) { + final streamChatTheme = StreamChatTheme.of(context); + final colorTheme = streamChatTheme.colorTheme; + if (isMessageRead) { return StreamSvgIcon( size: size, icon: StreamSvgIcons.checkAll, - color: StreamChatTheme.of(context).colorTheme.accentPrimary, + color: colorTheme.accentPrimary, ); } + + if (isMessageDelivered) { + return StreamSvgIcon( + size: size, + icon: StreamSvgIcons.checkAll, + color: colorTheme.textLowEmphasis, + ); + } + if (message.state.isCompleted) { return StreamSvgIcon( size: size, icon: StreamSvgIcons.check, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + color: colorTheme.textLowEmphasis, ); } + if (message.state.isOutgoing) { return StreamSvgIcon( size: size, icon: StreamSvgIcons.time, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + color: colorTheme.textLowEmphasis, ); } + return const Empty(); } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart b/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart index 0a7b6c4121..cb42429007 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart @@ -63,15 +63,16 @@ class SendingIndicatorBuilder extends StatelessWidget { stream: channel.state?.readStream, initialData: channel.state?.read, builder: (context, data) { - final readList = data.where((it) => - it.user.id != streamChat.currentUser?.id && - (it.lastRead.isAfter(message.createdAt) || - it.lastRead.isAtSameMomentAs(message.createdAt))); - + final readList = data.readsOf(message: message); final isMessageRead = readList.isNotEmpty; + + final deliveriesList = data.deliveriesOf(message: message); + final isMessageDelivered = deliveriesList.isNotEmpty; + Widget child = StreamSendingIndicator( message: message, isMessageRead: isMessageRead, + isMessageDelivered: isMessageDelivered, size: style?.fontSize, ); diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png index be1ab4d6f5..efb6048218 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png index 0c31a3c88b..be1ab4d6f5 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png new file mode 100644 index 0000000000..0c31a3c88b Binary files /dev/null and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart index 334ab9d466..a686a966e3 100644 --- a/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart @@ -47,7 +47,8 @@ void main() { ); goldenTest( - 'golden test for StreamSendingIndicator with StreamSvgIcon.check', + 'golden test for StreamSendingIndicator with StreamSvgIcon.checkAll ' + '(delivered)', fileName: 'sending_indicator_1', constraints: const BoxConstraints.tightFor(width: 50, height: 50), builder: () => MaterialAppWrapper( @@ -56,6 +57,7 @@ void main() { child: Scaffold( body: Center( child: StreamSendingIndicator( + isMessageDelivered: true, message: Message( state: MessageState.sent, ), @@ -67,9 +69,29 @@ void main() { ); goldenTest( - 'golden test for StreamSendingIndicator with StreamSvgIcons.time', + 'golden test for StreamSendingIndicator with StreamSvgIcon.check', fileName: 'sending_indicator_2', constraints: const BoxConstraints.tightFor(width: 50, height: 50), + builder: () => MaterialAppWrapper( + home: StreamChatTheme( + data: StreamChatThemeData.light(), + child: Scaffold( + body: Center( + child: StreamSendingIndicator( + message: Message( + state: MessageState.sent, + ), + ), + ), + ), + ), + ), + ); + + goldenTest( + 'golden test for StreamSendingIndicator with StreamSvgIcons.time', + fileName: 'sending_indicator_3', + constraints: const BoxConstraints.tightFor(width: 50, height: 50), builder: () => MaterialAppWrapper( home: StreamChatTheme( data: StreamChatThemeData.light(), @@ -85,4 +107,105 @@ void main() { ), ), ); + + testWidgets( + 'shows checkAll icon with textLowEmphasis color when message is delivered', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData.light(), + child: Scaffold( + body: Center( + child: StreamSendingIndicator( + isMessageDelivered: true, + message: Message( + state: MessageState.sent, + ), + ), + ), + ), + ), + ), + ); + + final streamSvgIcon = tester.widget( + find.byType(StreamSvgIcon), + ); + + expect(streamSvgIcon.icon, StreamSvgIcons.checkAll); + expect( + streamSvgIcon.color, + StreamChatThemeData.light().colorTheme.textLowEmphasis, + ); + }, + ); + + testWidgets( + 'shows checkAll icon with accentPrimary color when message is read', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData.light(), + child: Scaffold( + body: Center( + child: StreamSendingIndicator( + isMessageRead: true, + message: Message( + state: MessageState.sent, + ), + ), + ), + ), + ), + ), + ); + + final streamSvgIcon = tester.widget( + find.byType(StreamSvgIcon), + ); + + expect(streamSvgIcon.icon, StreamSvgIcons.checkAll); + expect( + streamSvgIcon.color, + StreamChatThemeData.light().colorTheme.accentPrimary, + ); + }, + ); + + testWidgets( + 'prioritizes read over delivered when both are true', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData.light(), + child: Scaffold( + body: Center( + child: StreamSendingIndicator( + isMessageRead: true, + isMessageDelivered: true, + message: Message( + state: MessageState.sent, + ), + ), + ), + ), + ), + ), + ); + + final streamSvgIcon = tester.widget( + find.byType(StreamSvgIcon), + ); + + expect(streamSvgIcon.icon, StreamSvgIcons.checkAll); + // Should use accentPrimary (read) not textLowEmphasis (delivered) + expect( + streamSvgIcon.color, + StreamChatThemeData.light().colorTheme.accentPrimary, + ); + }, + ); } diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 54f67f6a6b..39a4fa6ea3 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +โœ… Added + +- Added support for `Read.lastDeliveredAt` and `Read.lastDeliveredMessageId` fields to track message + delivery receipts. + ## 9.19.0 - Updated `stream_chat` dependency to [`9.19.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index f27eacfcb3..664b3ad14b 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart @@ -55,7 +55,7 @@ class DriftChatDatabase extends _$DriftChatDatabase { // you should bump this number whenever you change or add a table definition. @override - int get schemaVersion => 25; + int get schemaVersion => 26; @override MigrationStrategy get migration => MigrationStrategy( diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index 837519adfb..78b6e75de3 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart @@ -7558,9 +7558,28 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { late final GeneratedColumn lastReadMessageId = GeneratedColumn('last_read_message_id', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false); + static const VerificationMeta _lastDeliveredAtMeta = + const VerificationMeta('lastDeliveredAt'); @override - List get $columns => - [lastRead, userId, channelCid, unreadMessages, lastReadMessageId]; + late final GeneratedColumn lastDeliveredAt = + GeneratedColumn('last_delivered_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _lastDeliveredMessageIdMeta = + const VerificationMeta('lastDeliveredMessageId'); + @override + late final GeneratedColumn lastDeliveredMessageId = + GeneratedColumn('last_delivered_message_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + lastRead, + userId, + channelCid, + unreadMessages, + lastReadMessageId, + lastDeliveredAt, + lastDeliveredMessageId + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -7603,6 +7622,18 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { lastReadMessageId.isAcceptableOrUnknown( data['last_read_message_id']!, _lastReadMessageIdMeta)); } + if (data.containsKey('last_delivered_at')) { + context.handle( + _lastDeliveredAtMeta, + lastDeliveredAt.isAcceptableOrUnknown( + data['last_delivered_at']!, _lastDeliveredAtMeta)); + } + if (data.containsKey('last_delivered_message_id')) { + context.handle( + _lastDeliveredMessageIdMeta, + lastDeliveredMessageId.isAcceptableOrUnknown( + data['last_delivered_message_id']!, _lastDeliveredMessageIdMeta)); + } return context; } @@ -7622,6 +7653,11 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { .read(DriftSqlType.int, data['${effectivePrefix}unread_messages'])!, lastReadMessageId: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}last_read_message_id']), + lastDeliveredAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}last_delivered_at']), + lastDeliveredMessageId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_delivered_message_id']), ); } @@ -7646,12 +7682,20 @@ class ReadEntity extends DataClass implements Insertable { /// Id of the last read message final String? lastReadMessageId; + + /// Date of the last delivered message + final DateTime? lastDeliveredAt; + + /// Id of the last delivered message + final String? lastDeliveredMessageId; const ReadEntity( {required this.lastRead, required this.userId, required this.channelCid, required this.unreadMessages, - this.lastReadMessageId}); + this.lastReadMessageId, + this.lastDeliveredAt, + this.lastDeliveredMessageId}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -7662,6 +7706,13 @@ class ReadEntity extends DataClass implements Insertable { if (!nullToAbsent || lastReadMessageId != null) { map['last_read_message_id'] = Variable(lastReadMessageId); } + if (!nullToAbsent || lastDeliveredAt != null) { + map['last_delivered_at'] = Variable(lastDeliveredAt); + } + if (!nullToAbsent || lastDeliveredMessageId != null) { + map['last_delivered_message_id'] = + Variable(lastDeliveredMessageId); + } return map; } @@ -7675,6 +7726,9 @@ class ReadEntity extends DataClass implements Insertable { unreadMessages: serializer.fromJson(json['unreadMessages']), lastReadMessageId: serializer.fromJson(json['lastReadMessageId']), + lastDeliveredAt: serializer.fromJson(json['lastDeliveredAt']), + lastDeliveredMessageId: + serializer.fromJson(json['lastDeliveredMessageId']), ); } @override @@ -7686,6 +7740,9 @@ class ReadEntity extends DataClass implements Insertable { 'channelCid': serializer.toJson(channelCid), 'unreadMessages': serializer.toJson(unreadMessages), 'lastReadMessageId': serializer.toJson(lastReadMessageId), + 'lastDeliveredAt': serializer.toJson(lastDeliveredAt), + 'lastDeliveredMessageId': + serializer.toJson(lastDeliveredMessageId), }; } @@ -7694,7 +7751,9 @@ class ReadEntity extends DataClass implements Insertable { String? userId, String? channelCid, int? unreadMessages, - Value lastReadMessageId = const Value.absent()}) => + Value lastReadMessageId = const Value.absent(), + Value lastDeliveredAt = const Value.absent(), + Value lastDeliveredMessageId = const Value.absent()}) => ReadEntity( lastRead: lastRead ?? this.lastRead, userId: userId ?? this.userId, @@ -7703,6 +7762,12 @@ class ReadEntity extends DataClass implements Insertable { lastReadMessageId: lastReadMessageId.present ? lastReadMessageId.value : this.lastReadMessageId, + lastDeliveredAt: lastDeliveredAt.present + ? lastDeliveredAt.value + : this.lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId.present + ? lastDeliveredMessageId.value + : this.lastDeliveredMessageId, ); ReadEntity copyWithCompanion(ReadsCompanion data) { return ReadEntity( @@ -7716,6 +7781,12 @@ class ReadEntity extends DataClass implements Insertable { lastReadMessageId: data.lastReadMessageId.present ? data.lastReadMessageId.value : this.lastReadMessageId, + lastDeliveredAt: data.lastDeliveredAt.present + ? data.lastDeliveredAt.value + : this.lastDeliveredAt, + lastDeliveredMessageId: data.lastDeliveredMessageId.present + ? data.lastDeliveredMessageId.value + : this.lastDeliveredMessageId, ); } @@ -7726,14 +7797,16 @@ class ReadEntity extends DataClass implements Insertable { ..write('userId: $userId, ') ..write('channelCid: $channelCid, ') ..write('unreadMessages: $unreadMessages, ') - ..write('lastReadMessageId: $lastReadMessageId') + ..write('lastReadMessageId: $lastReadMessageId, ') + ..write('lastDeliveredAt: $lastDeliveredAt, ') + ..write('lastDeliveredMessageId: $lastDeliveredMessageId') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( - lastRead, userId, channelCid, unreadMessages, lastReadMessageId); + int get hashCode => Object.hash(lastRead, userId, channelCid, unreadMessages, + lastReadMessageId, lastDeliveredAt, lastDeliveredMessageId); @override bool operator ==(Object other) => identical(this, other) || @@ -7742,7 +7815,9 @@ class ReadEntity extends DataClass implements Insertable { other.userId == this.userId && other.channelCid == this.channelCid && other.unreadMessages == this.unreadMessages && - other.lastReadMessageId == this.lastReadMessageId); + other.lastReadMessageId == this.lastReadMessageId && + other.lastDeliveredAt == this.lastDeliveredAt && + other.lastDeliveredMessageId == this.lastDeliveredMessageId); } class ReadsCompanion extends UpdateCompanion { @@ -7751,6 +7826,8 @@ class ReadsCompanion extends UpdateCompanion { final Value channelCid; final Value unreadMessages; final Value lastReadMessageId; + final Value lastDeliveredAt; + final Value lastDeliveredMessageId; final Value rowid; const ReadsCompanion({ this.lastRead = const Value.absent(), @@ -7758,6 +7835,8 @@ class ReadsCompanion extends UpdateCompanion { this.channelCid = const Value.absent(), this.unreadMessages = const Value.absent(), this.lastReadMessageId = const Value.absent(), + this.lastDeliveredAt = const Value.absent(), + this.lastDeliveredMessageId = const Value.absent(), this.rowid = const Value.absent(), }); ReadsCompanion.insert({ @@ -7766,6 +7845,8 @@ class ReadsCompanion extends UpdateCompanion { required String channelCid, this.unreadMessages = const Value.absent(), this.lastReadMessageId = const Value.absent(), + this.lastDeliveredAt = const Value.absent(), + this.lastDeliveredMessageId = const Value.absent(), this.rowid = const Value.absent(), }) : lastRead = Value(lastRead), userId = Value(userId), @@ -7776,6 +7857,8 @@ class ReadsCompanion extends UpdateCompanion { Expression? channelCid, Expression? unreadMessages, Expression? lastReadMessageId, + Expression? lastDeliveredAt, + Expression? lastDeliveredMessageId, Expression? rowid, }) { return RawValuesInsertable({ @@ -7784,6 +7867,9 @@ class ReadsCompanion extends UpdateCompanion { if (channelCid != null) 'channel_cid': channelCid, if (unreadMessages != null) 'unread_messages': unreadMessages, if (lastReadMessageId != null) 'last_read_message_id': lastReadMessageId, + if (lastDeliveredAt != null) 'last_delivered_at': lastDeliveredAt, + if (lastDeliveredMessageId != null) + 'last_delivered_message_id': lastDeliveredMessageId, if (rowid != null) 'rowid': rowid, }); } @@ -7794,6 +7880,8 @@ class ReadsCompanion extends UpdateCompanion { Value? channelCid, Value? unreadMessages, Value? lastReadMessageId, + Value? lastDeliveredAt, + Value? lastDeliveredMessageId, Value? rowid}) { return ReadsCompanion( lastRead: lastRead ?? this.lastRead, @@ -7801,6 +7889,9 @@ class ReadsCompanion extends UpdateCompanion { channelCid: channelCid ?? this.channelCid, unreadMessages: unreadMessages ?? this.unreadMessages, lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, + lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, + lastDeliveredMessageId: + lastDeliveredMessageId ?? this.lastDeliveredMessageId, rowid: rowid ?? this.rowid, ); } @@ -7823,6 +7914,13 @@ class ReadsCompanion extends UpdateCompanion { if (lastReadMessageId.present) { map['last_read_message_id'] = Variable(lastReadMessageId.value); } + if (lastDeliveredAt.present) { + map['last_delivered_at'] = Variable(lastDeliveredAt.value); + } + if (lastDeliveredMessageId.present) { + map['last_delivered_message_id'] = + Variable(lastDeliveredMessageId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -7837,6 +7935,8 @@ class ReadsCompanion extends UpdateCompanion { ..write('channelCid: $channelCid, ') ..write('unreadMessages: $unreadMessages, ') ..write('lastReadMessageId: $lastReadMessageId, ') + ..write('lastDeliveredAt: $lastDeliveredAt, ') + ..write('lastDeliveredMessageId: $lastDeliveredMessageId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -13269,6 +13369,8 @@ typedef $$ReadsTableCreateCompanionBuilder = ReadsCompanion Function({ required String channelCid, Value unreadMessages, Value lastReadMessageId, + Value lastDeliveredAt, + Value lastDeliveredMessageId, Value rowid, }); typedef $$ReadsTableUpdateCompanionBuilder = ReadsCompanion Function({ @@ -13277,6 +13379,8 @@ typedef $$ReadsTableUpdateCompanionBuilder = ReadsCompanion Function({ Value channelCid, Value unreadMessages, Value lastReadMessageId, + Value lastDeliveredAt, + Value lastDeliveredMessageId, Value rowid, }); @@ -13322,6 +13426,14 @@ class $$ReadsTableFilterComposer column: $table.lastReadMessageId, builder: (column) => ColumnFilters(column)); + ColumnFilters get lastDeliveredAt => $composableBuilder( + column: $table.lastDeliveredAt, + builder: (column) => ColumnFilters(column)); + + ColumnFilters get lastDeliveredMessageId => $composableBuilder( + column: $table.lastDeliveredMessageId, + builder: (column) => ColumnFilters(column)); + $$ChannelsTableFilterComposer get channelCid { final $$ChannelsTableFilterComposer composer = $composerBuilder( composer: this, @@ -13366,6 +13478,14 @@ class $$ReadsTableOrderingComposer column: $table.lastReadMessageId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastDeliveredAt => $composableBuilder( + column: $table.lastDeliveredAt, + builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get lastDeliveredMessageId => $composableBuilder( + column: $table.lastDeliveredMessageId, + builder: (column) => ColumnOrderings(column)); + $$ChannelsTableOrderingComposer get channelCid { final $$ChannelsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -13408,6 +13528,12 @@ class $$ReadsTableAnnotationComposer GeneratedColumn get lastReadMessageId => $composableBuilder( column: $table.lastReadMessageId, builder: (column) => column); + GeneratedColumn get lastDeliveredAt => $composableBuilder( + column: $table.lastDeliveredAt, builder: (column) => column); + + GeneratedColumn get lastDeliveredMessageId => $composableBuilder( + column: $table.lastDeliveredMessageId, builder: (column) => column); + $$ChannelsTableAnnotationComposer get channelCid { final $$ChannelsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -13457,6 +13583,8 @@ class $$ReadsTableTableManager extends RootTableManager< Value channelCid = const Value.absent(), Value unreadMessages = const Value.absent(), Value lastReadMessageId = const Value.absent(), + Value lastDeliveredAt = const Value.absent(), + Value lastDeliveredMessageId = const Value.absent(), Value rowid = const Value.absent(), }) => ReadsCompanion( @@ -13465,6 +13593,8 @@ class $$ReadsTableTableManager extends RootTableManager< channelCid: channelCid, unreadMessages: unreadMessages, lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, rowid: rowid, ), createCompanionCallback: ({ @@ -13473,6 +13603,8 @@ class $$ReadsTableTableManager extends RootTableManager< required String channelCid, Value unreadMessages = const Value.absent(), Value lastReadMessageId = const Value.absent(), + Value lastDeliveredAt = const Value.absent(), + Value lastDeliveredMessageId = const Value.absent(), Value rowid = const Value.absent(), }) => ReadsCompanion.insert( @@ -13481,6 +13613,8 @@ class $$ReadsTableTableManager extends RootTableManager< channelCid: channelCid, unreadMessages: unreadMessages, lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/packages/stream_chat_persistence/lib/src/entity/reads.dart b/packages/stream_chat_persistence/lib/src/entity/reads.dart index b8a8ac22be..2a842d3d69 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reads.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reads.dart @@ -21,6 +21,12 @@ class Reads extends Table { /// Id of the last read message TextColumn get lastReadMessageId => text().nullable()(); + /// Date of the last delivered message + DateTimeColumn get lastDeliveredAt => dateTime().nullable()(); + + /// Id of the last delivered message + TextColumn get lastDeliveredMessageId => text().nullable()(); + @override Set get primaryKey => { userId, diff --git a/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart index 9e75a0e074..bdcaefc70e 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart @@ -9,6 +9,8 @@ extension ReadEntityX on ReadEntity { lastRead: lastRead, unreadMessages: unreadMessages, lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, ); } @@ -21,5 +23,7 @@ extension ReadX on Read { channelCid: cid, unreadMessages: unreadMessages, lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, ); } diff --git a/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart index aae58e83f6..142daf4155 100644 --- a/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart @@ -25,6 +25,8 @@ void main() { user: users[index], unreadMessages: index + 10, lastReadMessageId: 'lastMessageId$index', + lastDeliveredAt: DateTime.now(), + lastDeliveredMessageId: 'lastDeliveredMessageId$index', ), ); @@ -54,6 +56,10 @@ void main() { expect(fetchedRead.user.id, insertedRead.user.id); expect(fetchedRead.lastRead, isSameDateAs(insertedRead.lastRead)); expect(fetchedRead.unreadMessages, insertedRead.unreadMessages); + expect(fetchedRead.lastDeliveredAt, + isSameDateAs(insertedRead.lastDeliveredAt)); + expect(fetchedRead.lastDeliveredMessageId, + insertedRead.lastDeliveredMessageId); } }); @@ -71,6 +77,8 @@ void main() { user: newUser, unreadMessages: 30, lastReadMessageId: 'lastMessageId3', + lastDeliveredAt: DateTime.now(), + lastDeliveredMessageId: 'lastDeliveredMessageId3', ); await database.userDao.updateUsers([newUser]); await readDao.updateReads(cid, [copyRead, newRead]); diff --git a/packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart index fef6328491..46700d7aad 100644 --- a/packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/read_mapper_test.dart @@ -9,13 +9,17 @@ void main() { test('toRead should map entity into Read', () { const cid = 'testCid'; const lastMessageId = 'lastMessageId'; + const lastDeliveredMessageId = 'lastDeliveredMessageId'; final user = User(id: 'testUserId'); + final lastDeliveredAt = DateTime.now(); final entity = ReadEntity( lastRead: DateTime.now(), userId: user.id, channelCid: cid, unreadMessages: 33, lastReadMessageId: lastMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, ); final read = entity.toRead(user: user); @@ -24,17 +28,23 @@ void main() { expect(read.user.id, entity.userId); expect(read.unreadMessages, entity.unreadMessages); expect(read.lastReadMessageId, lastMessageId); + expect(read.lastDeliveredAt, isSameDateAs(lastDeliveredAt)); + expect(read.lastDeliveredMessageId, lastDeliveredMessageId); }); test('toEntity should map read into ReadEntity', () { const cid = 'testCid'; const lastMessageId = 'lastMessageId'; + const lastDeliveredMessageId = 'lastDeliveredMessageId'; final user = User(id: 'testUserId'); + final lastDeliveredAt = DateTime.now(); final read = Read( lastRead: DateTime.now(), user: user, unreadMessages: 33, lastReadMessageId: lastMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, ); final entity = read.toEntity(cid: cid); @@ -43,5 +53,7 @@ void main() { expect(entity.userId, read.user.id); expect(entity.unreadMessages, read.unreadMessages); expect(entity.lastReadMessageId, lastMessageId); + expect(entity.lastDeliveredAt, isSameDateAs(lastDeliveredAt)); + expect(entity.lastDeliveredMessageId, lastDeliveredMessageId); }); } diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index 846aad04d0..d1e8e213c8 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/thread_page.dart'; import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/widgets/message_info_sheet.dart'; import 'package:sample_app/widgets/reminder_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -225,7 +226,19 @@ class _ChannelPageState extends State { }, ), ], - ] + ], + if (channelConfig?.deliveryEvents == true) + StreamMessageAction( + leading: Icon( + Icons.info_outline_rounded, + color: colorTheme.textLowEmphasis, + ), + title: const Text('Message Info'), + onTap: (message) { + Navigator.of(context).pop(); + MessageInfoSheet.show(context: context, message: message); + }, + ), ]; return Container( diff --git a/sample_app/lib/widgets/message_info_sheet.dart b/sample_app/lib/widgets/message_info_sheet.dart new file mode 100644 index 0000000000..6f0a8cec57 --- /dev/null +++ b/sample_app/lib/widgets/message_info_sheet.dart @@ -0,0 +1,286 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A bottom sheet that displays delivery and read receipt information +/// for a message, similar to popular messaging apps like WhatsApp. +class MessageInfoSheet extends StatelessWidget { + /// Creates a new [MessageInfoSheet]. + const MessageInfoSheet({ + super.key, + required this.message, + this.scrollController, + }); + + /// The message to display info for. + final Message message; + final ScrollController? scrollController; + + /// Shows the message info sheet as a modal bottom sheet. + static Future show({ + required BuildContext context, + required Message message, + }) { + final theme = StreamChatTheme.of(context); + return showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + backgroundColor: theme.colorTheme.appBg, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + ), + builder: (_) => StreamChannel( + channel: StreamChannel.of(context).channel, + child: DraggableScrollableSheet( + snap: true, + expand: false, + snapSizes: const [0.5, 1], + builder: (context, controller) => MessageInfoSheet( + message: message, + scrollController: controller, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + final textTheme = theme.textTheme; + + final channel = StreamChannel.of(context).channel; + + return Column( + children: [ + // Header + _buildHeader(context), + + // Delivery and read receipts + Expanded( + child: BetterStreamBuilder>( + stream: channel.state?.readStream, + initialData: channel.state?.read, + noDataBuilder: (context) => Center( + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation(colorTheme.accentPrimary), + ), + ), + builder: (context, reads) { + final readBy = reads.readsOf(message: message); + final deliveredTo = reads.deliveriesOf(message: message); + + // Empty state + if (readBy.isEmpty && deliveredTo.isEmpty) { + return Center( + child: Column( + spacing: 16, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outline, + size: 56, + color: colorTheme.textLowEmphasis, + ), + Text( + 'No delivery information available', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + // Read section + if (readBy.isNotEmpty) ...[ + _buildSection( + context, + title: 'READ BY', + reads: readBy, + itemBuilder: (_, read) => _UserReadTile( + read: read, + ), + ), + const SizedBox(height: 32), + ], + + // Delivered section + if (deliveredTo.isNotEmpty) ...[ + _buildSection( + context, + title: 'DELIVERED TO', + reads: deliveredTo, + itemBuilder: (_, read) => _UserReadTile( + read: read, + isDelivered: true, + ), + ), + ], + ], + ); + }, + ), + ), + ], + ); + } + + Widget _buildHeader(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colorTheme.borders, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Message Info', + style: textTheme.headlineBold, + ), + IconButton( + iconSize: 32, + icon: const StreamSvgIcon(icon: StreamSvgIcons.close), + onPressed: Navigator.of(context).maybePop, + color: colorTheme.textHighEmphasis, + padding: const EdgeInsets.all(4), + style: ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: WidgetStateProperty.all(const Size.square(32)), + ), + ), + ], + ), + ); + } + + Widget _buildSection( + BuildContext context, { + required String title, + required List reads, + required Widget Function(BuildContext context, Read item) itemBuilder, + }) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + final textTheme = theme.textTheme; + + return Column( + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section title + Text( + title, + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + + // List of items + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorTheme.barsBg, + ), + child: MediaQuery.removePadding( + context: context, + // Workaround for the bottom padding issue. + // Link: https://github.com/flutter/flutter/issues/156149 + removeTop: true, + removeBottom: true, + child: ListView.separated( + shrinkWrap: true, + itemCount: reads.length, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, __) => Divider( + height: 1, + color: theme.colorTheme.borders, + ), + itemBuilder: (_, index) => itemBuilder( + context, + reads[index], + ), + ), + ), + ), + ), + ], + ); + } +} + +/// Tile displaying a user's read/delivery status +class _UserReadTile extends StatelessWidget { + const _UserReadTile({ + required this.read, + this.isDelivered = false, + }); + + final Read read; + final bool isDelivered; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + // User avatar + StreamUserAvatar( + user: read.user, + constraints: const BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + + const SizedBox(width: 12), + + // User name + Expanded( + child: Text( + read.user.name, + style: theme.textTheme.bodyBold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // Status icon + StreamSvgIcon( + size: 18, + icon: StreamSvgIcons.checkAll, + color: switch (isDelivered) { + true => theme.colorTheme.textLowEmphasis, + false => theme.colorTheme.accentPrimary, + }, + ), + ], + ), + ); + } +}