diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index f5c8bcc01..5ab11ff62 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -5,6 +5,11 @@ - Fixed cached messages are cleared from channels with unread messages when accessed offline. [[#2083]](https://github.com/GetStream/stream-chat-flutter/issues/2083) +✅ Added + +- Added support for `client.getUnreadCount()`, which returns the unread count information for the + current user. + 🔄 Changed - Deprecated `SortOption.new` constructor in favor of `SortOption.desc` and `SortOption.asc`. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index f848182cf..a58290cbc 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1547,6 +1547,21 @@ class StreamChatClient { } } + /// Returns the unread count information for the current user. + Future getUnreadCount() async { + final response = await _chatApi.user.getUnreadCount(); + + // Emit an local event with the unread count information as a side effect + // in order to update the current user state. + handleEvent(Event( + totalUnreadCount: response.totalUnreadCount, + unreadChannels: response.channels.length, + unreadThreads: response.threads.length, + )); + + return response; + } + /// Mutes a user Future muteUser(String userId) => _chatApi.moderation.muteUser(userId); diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 0602e71f3..dbd8ed3bd 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -18,6 +18,7 @@ import 'package:stream_chat/src/core/models/poll_vote.dart'; import 'package:stream_chat/src/core/models/reaction.dart'; import 'package:stream_chat/src/core/models/read.dart'; import 'package:stream_chat/src/core/models/thread.dart'; +import 'package:stream_chat/src/core/models/unread_counts.dart'; import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/models/user_block.dart'; @@ -802,3 +803,29 @@ class QueryRemindersResponse extends _BaseResponse { static QueryRemindersResponse fromJson(Map json) => _$QueryRemindersResponseFromJson(json); } + +/// Model response for [StreamChatClient.getUnreadCount] api call +@JsonSerializable(createToJson: false) +class GetUnreadCountResponse extends _BaseResponse { + /// Total number of unread messages across all channels + late int totalUnreadCount; + + /// Total number of threads with unread replies + late int totalUnreadThreadsCount; + + /// Total number of unread messages grouped by team + late Map? totalUnreadCountByTeam; + + /// List of channels with unread messages + late List channels; + + /// Summary of unread counts grouped by channel type + late List channelType; + + /// List of threads with unread replies + late List threads; + + /// Create a new instance from a json + static GetUnreadCountResponse fromJson(Map json) => + _$GetUnreadCountResponseFromJson(json); +} diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 75dcc1595..3583ffdf3 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -467,3 +467,25 @@ QueryRemindersResponse _$QueryRemindersResponseFromJson( .toList() ?? [] ..next = json['next'] as String?; + +GetUnreadCountResponse _$GetUnreadCountResponseFromJson( + Map json) => + GetUnreadCountResponse() + ..duration = json['duration'] as String? + ..totalUnreadCount = (json['total_unread_count'] as num).toInt() + ..totalUnreadThreadsCount = + (json['total_unread_threads_count'] as num).toInt() + ..totalUnreadCountByTeam = + (json['total_unread_count_by_team'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ) + ..channels = (json['channels'] as List) + .map((e) => UnreadCountsChannel.fromJson(e as Map)) + .toList() + ..channelType = (json['channel_type'] as List) + .map((e) => + UnreadCountsChannelType.fromJson(e as Map)) + .toList() + ..threads = (json['threads'] as List) + .map((e) => UnreadCountsThread.fromJson(e as Map)) + .toList(); diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index c94c344be..064792bfe 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -88,4 +88,11 @@ class UserApi { return BlockedUsersResponse.fromJson(response.data); } + + /// Requests the unread count information for the current user. + Future getUnreadCount() async { + final response = await _client.get('/unread'); + + return GetUnreadCountResponse.fromJson(response.data); + } } diff --git a/packages/stream_chat/lib/src/core/models/unread_counts.dart b/packages/stream_chat/lib/src/core/models/unread_counts.dart new file mode 100644 index 000000000..d3e265fa7 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/unread_counts.dart @@ -0,0 +1,95 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'unread_counts.g.dart'; + +/// {@template unreadCountsChannel} +/// A model class representing information for a specific channel. +/// {@endtemplate} +@JsonSerializable() +class UnreadCountsChannel { + /// {@macro unreadCountsChannel} + const UnreadCountsChannel({ + required this.channelId, + required this.unreadCount, + required this.lastRead, + }); + + /// Create a new instance from a json. + factory UnreadCountsChannel.fromJson(Map json) => + _$UnreadCountsChannelFromJson(json); + + /// The unique identifier of the channel (format: "type:id"). + final String channelId; + + /// Number of unread messages in this channel. + final int unreadCount; + + /// Timestamp when the channel was last read by the user. + final DateTime lastRead; + + /// Serializes this instance to a JSON map. + Map toJson() => _$UnreadCountsChannelToJson(this); +} + +/// {@template unreadCountsThread} +/// A model class representing unread count information for a specific thread. +/// {@endtemplate} +@JsonSerializable() +class UnreadCountsThread { + /// {@macro unreadCountsThread} + const UnreadCountsThread({ + required this.unreadCount, + required this.lastRead, + required this.lastReadMessageId, + required this.parentMessageId, + }); + + /// Create a new instance from a json. + factory UnreadCountsThread.fromJson(Map json) => + _$UnreadCountsThreadFromJson(json); + + /// Number of unread messages in this thread. + final int unreadCount; + + /// Timestamp when the thread was last read by the user. + final DateTime lastRead; + + /// ID of the last message that was read in this thread. + final String lastReadMessageId; + + /// ID of the parent message that started this thread. + final String parentMessageId; + + /// Serializes this instance to a JSON map. + Map toJson() => _$UnreadCountsThreadToJson(this); +} + +/// {@template unreadCountsChannelType} +/// A model class representing aggregated unread count information for a +/// specific channel type. +/// {@endtemplate} +@JsonSerializable() +class UnreadCountsChannelType { + /// {@macro unreadCountsChannelType} + const UnreadCountsChannelType({ + required this.channelType, + required this.channelCount, + required this.unreadCount, + }); + + /// Create a new instance from a json. + factory UnreadCountsChannelType.fromJson(Map json) => + _$UnreadCountsChannelTypeFromJson(json); + + /// The type of channel (e.g., "messaging", "livestream", "team"). + final String channelType; + + /// Number of channels of this type that have unread messages. + final int channelCount; + + /// Total number of unread messages across all channels of this type. + final int unreadCount; + + /// Serializes this instance to a JSON map. + Map toJson() => _$UnreadCountsChannelTypeToJson(this); +} diff --git a/packages/stream_chat/lib/src/core/models/unread_counts.g.dart b/packages/stream_chat/lib/src/core/models/unread_counts.g.dart new file mode 100644 index 000000000..bd149b5a7 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/unread_counts.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'unread_counts.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UnreadCountsChannel _$UnreadCountsChannelFromJson(Map json) => + UnreadCountsChannel( + channelId: json['channel_id'] as String, + unreadCount: (json['unread_count'] as num).toInt(), + lastRead: DateTime.parse(json['last_read'] as String), + ); + +Map _$UnreadCountsChannelToJson( + UnreadCountsChannel instance) => + { + 'channel_id': instance.channelId, + 'unread_count': instance.unreadCount, + 'last_read': instance.lastRead.toIso8601String(), + }; + +UnreadCountsThread _$UnreadCountsThreadFromJson(Map json) => + UnreadCountsThread( + unreadCount: (json['unread_count'] as num).toInt(), + lastRead: DateTime.parse(json['last_read'] as String), + lastReadMessageId: json['last_read_message_id'] as String, + parentMessageId: json['parent_message_id'] as String, + ); + +Map _$UnreadCountsThreadToJson(UnreadCountsThread instance) => + { + 'unread_count': instance.unreadCount, + 'last_read': instance.lastRead.toIso8601String(), + 'last_read_message_id': instance.lastReadMessageId, + 'parent_message_id': instance.parentMessageId, + }; + +UnreadCountsChannelType _$UnreadCountsChannelTypeFromJson( + Map json) => + UnreadCountsChannelType( + channelType: json['channel_type'] as String, + channelCount: (json['channel_count'] as num).toInt(), + unreadCount: (json['unread_count'] as num).toInt(), + ); + +Map _$UnreadCountsChannelTypeToJson( + UnreadCountsChannelType instance) => + { + 'channel_type': instance.channelType, + 'channel_count': instance.channelCount, + 'unread_count': instance.unreadCount, + }; diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index ea60b484b..1a6b1844d 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -58,6 +58,7 @@ export 'src/core/models/reaction_group.dart'; export 'src/core/models/read.dart'; export 'src/core/models/thread.dart'; export 'src/core/models/thread_participant.dart'; +export 'src/core/models/unread_counts.dart'; export 'src/core/models/user.dart'; export 'src/core/models/user_block.dart'; export 'src/core/platform_detector/platform_detector.dart'; diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 2b1062096..9ea62a75c 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2631,6 +2631,100 @@ void main() { ); }); + test('`.getUnreadCount`', () async { + when(() => api.user.getUnreadCount()).thenAnswer( + (_) async => GetUnreadCountResponse() + ..totalUnreadCount = 42 + ..totalUnreadThreadsCount = 8 + ..channelType = [] + ..channels = [ + UnreadCountsChannel( + channelId: 'messaging:test-channel-1', + unreadCount: 10, + lastRead: DateTime.now(), + ), + UnreadCountsChannel( + channelId: 'messaging:test-channel-2', + unreadCount: 15, + lastRead: DateTime.now(), + ), + ] + ..threads = [ + UnreadCountsThread( + unreadCount: 3, + lastRead: DateTime.now(), + lastReadMessageId: 'message-1', + parentMessageId: 'parent-message-1', + ), + UnreadCountsThread( + unreadCount: 5, + lastRead: DateTime.now(), + lastReadMessageId: 'message-2', + parentMessageId: 'parent-message-2', + ), + ], + ); + + final res = await client.getUnreadCount(); + + expect(res, isNotNull); + expect(res.totalUnreadCount, 42); + expect(res.totalUnreadThreadsCount, 8); + + verify(() => api.user.getUnreadCount()).called(1); + verifyNoMoreInteractions(api.user); + }); + + test( + '`.getUnreadCount` should also update user unread count as a side effect', + () async { + when(() => api.user.getUnreadCount()).thenAnswer( + (_) async => GetUnreadCountResponse() + ..totalUnreadCount = 25 + ..totalUnreadThreadsCount = 2 + ..channelType = [] + ..channels = [ + UnreadCountsChannel( + channelId: 'messaging:test-channel-1', + unreadCount: 10, + lastRead: DateTime.now(), + ), + UnreadCountsChannel( + channelId: 'messaging:test-channel-2', + unreadCount: 15, + lastRead: DateTime.now(), + ), + ] + ..threads = [ + UnreadCountsThread( + unreadCount: 3, + lastRead: DateTime.now(), + lastReadMessageId: 'message-1', + parentMessageId: 'parent-message-1', + ), + UnreadCountsThread( + unreadCount: 5, + lastRead: DateTime.now(), + lastReadMessageId: 'message-2', + parentMessageId: 'parent-message-2', + ), + ], + ); + + client.getUnreadCount().ignore(); + + // Wait for the local side effect event to be processed + await Future.delayed(Duration.zero); + + expect(client.state.currentUser?.totalUnreadCount, 25); + expect(client.state.currentUser?.unreadChannels, 2); // channels.length + expect(client.state.currentUser?.unreadThreads, 2); // threads.length + + verify(() => api.user.getUnreadCount()).called(1); + verifyNoMoreInteractions(api.user); + }, + ); + test('`.shadowBan`', () async { const userId = 'test-user-id'; diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart index 75b392ebf..97eda7014 100644 --- a/packages/stream_chat/test/src/core/api/user_api_test.dart +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -182,4 +182,68 @@ void main() { verify(() => client.get(path)).called(1); verifyNoMoreInteractions(client); }); + + test('getUnreadCount', () async { + const path = '/unread'; + + when(() => client.get(path)).thenAnswer( + (_) async => successResponse(path, data: { + 'duration': '5.23ms', + 'total_unread_count': 42, + 'total_unread_threads_count': 8, + 'total_unread_count_by_team': {'team-1': 15, 'team-2': 27}, + 'channels': [ + { + 'channel_id': 'messaging:test-channel-1', + 'unread_count': 5, + 'last_read': '2024-01-15T10:30:00.000Z', + }, + { + 'channel_id': 'messaging:test-channel-2', + 'unread_count': 10, + 'last_read': '2024-01-15T09:15:00.000Z', + }, + ], + 'channel_type': [ + { + 'channel_type': 'messaging', + 'channel_count': 3, + 'unread_count': 25, + }, + { + 'channel_type': 'livestream', + 'channel_count': 1, + 'unread_count': 17, + }, + ], + 'threads': [ + { + 'unread_count': 3, + 'last_read': '2024-01-15T10:30:00.000Z', + 'last_read_message_id': 'message-1', + 'parent_message_id': 'parent-message-1', + }, + { + 'unread_count': 5, + 'last_read': '2024-01-15T09:45:00.000Z', + 'last_read_message_id': 'message-2', + 'parent_message_id': 'parent-message-2', + }, + ], + }), + ); + + final res = await userApi.getUnreadCount(); + + expect(res, isNotNull); + expect(res.totalUnreadCount, 42); + expect(res.totalUnreadThreadsCount, 8); + expect(res.totalUnreadCountByTeam, {'team-1': 15, 'team-2': 27}); + expect(res.channels.length, 2); + expect(res.channelType.length, 2); + expect(res.threads.length, 2); + + verify(() => client.get(path)).called(1); + verifyNoMoreInteractions(client); + }); } diff --git a/packages/stream_chat/test/src/core/models/unread_counts_test.dart b/packages/stream_chat/test/src/core/models/unread_counts_test.dart new file mode 100644 index 000000000..053b8db5f --- /dev/null +++ b/packages/stream_chat/test/src/core/models/unread_counts_test.dart @@ -0,0 +1,127 @@ +import 'package:stream_chat/src/core/models/unread_counts.dart'; +import 'package:test/test.dart'; + +void main() { + group('UnreadCountsChannel', () { + const channelId = 'messaging:test-channel-id'; + const unreadCount = 5; + final lastRead = DateTime.parse('2024-01-15T10:30:00Z'); + + test('should parse json correctly', () { + final json = { + 'channel_id': channelId, + 'unread_count': unreadCount, + 'last_read': '2024-01-15T10:30:00.000Z', + }; + + final unreadCountsChannel = UnreadCountsChannel.fromJson(json); + + expect(unreadCountsChannel.channelId, channelId); + expect(unreadCountsChannel.unreadCount, unreadCount); + expect(unreadCountsChannel.lastRead, lastRead); + }); + + test('should serialize to json correctly', () { + final unreadCountsChannel = UnreadCountsChannel( + channelId: channelId, + unreadCount: unreadCount, + lastRead: lastRead, + ); + + final json = unreadCountsChannel.toJson(); + + expect(json['channel_id'], channelId); + expect(json['unread_count'], unreadCount); + expect(json['last_read'], lastRead.toIso8601String()); + }); + }); + + group('UnreadCountsThread', () { + const unreadCount = 3; + final lastRead = DateTime.parse('2024-01-15T10:30:00Z'); + const lastReadMessageId = 'last-read-message-id'; + const parentMessageId = 'parent-message-id'; + + test('should parse json correctly', () { + final json = { + 'unread_count': unreadCount, + 'last_read': '2024-01-15T10:30:00.000Z', + 'last_read_message_id': lastReadMessageId, + 'parent_message_id': parentMessageId, + }; + + final unreadCountsThread = UnreadCountsThread.fromJson(json); + + expect(unreadCountsThread.unreadCount, unreadCount); + expect(unreadCountsThread.lastRead, lastRead); + expect(unreadCountsThread.lastReadMessageId, lastReadMessageId); + expect(unreadCountsThread.parentMessageId, parentMessageId); + }); + + test('should serialize to json correctly', () { + final unreadCountsThread = UnreadCountsThread( + unreadCount: unreadCount, + lastRead: lastRead, + lastReadMessageId: lastReadMessageId, + parentMessageId: parentMessageId, + ); + + final json = unreadCountsThread.toJson(); + + expect(json['unread_count'], unreadCount); + expect(json['last_read'], lastRead.toIso8601String()); + expect(json['last_read_message_id'], lastReadMessageId); + expect(json['parent_message_id'], parentMessageId); + }); + }); + + group('UnreadCountsChannelType', () { + const channelType = 'messaging'; + const channelCount = 10; + const unreadCount = 25; + + test('should parse json correctly', () { + final json = { + 'channel_type': channelType, + 'channel_count': channelCount, + 'unread_count': unreadCount, + }; + + final unreadCountsChannelType = UnreadCountsChannelType.fromJson(json); + + expect(unreadCountsChannelType.channelType, channelType); + expect(unreadCountsChannelType.channelCount, channelCount); + expect(unreadCountsChannelType.unreadCount, unreadCount); + }); + + test('should serialize to json correctly', () { + const unreadCountsChannelType = UnreadCountsChannelType( + channelType: channelType, + channelCount: channelCount, + unreadCount: unreadCount, + ); + + final json = unreadCountsChannelType.toJson(); + + expect(json['channel_type'], channelType); + expect(json['channel_count'], channelCount); + expect(json['unread_count'], unreadCount); + }); + + test('should handle different channel types', () { + final channelTypes = ['messaging', 'livestream', 'team', 'commerce']; + + for (final type in channelTypes) { + final unreadCountsChannelType = UnreadCountsChannelType( + channelType: type, + channelCount: channelCount, + unreadCount: unreadCount, + ); + + expect(unreadCountsChannelType.channelType, type); + expect(unreadCountsChannelType.channelCount, channelCount); + expect(unreadCountsChannelType.unreadCount, unreadCount); + } + }); + }); +}