diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 968b7e144f..8da0968d71 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -2,8 +2,14 @@ ✅ Added +- Added support for predefined filters for `QueryChannels` on `StreamChatClient` (`StreamChatClient.queryChannels` and `StreamChatClient.queryChannelsWithResult`). +- Added `ChatPersistenceClient.queryChannelStates` and `ChatPersistenceClient.saveChannelQueries` as the unified read/write methods for channel-query persistence. Both accept standard and predefined-filter parameters and internally dispatch. - Added `StreamChatClient.pauseReconnect` / `resumeReconnect` to suspend the WebSocket's auto-retry loop without tearing down the user session. +⚠️ Deprecated + +- Deprecated `ChatPersistenceClient.getChannelStates` and `ChatPersistenceClient.updateChannelQueries` in favor of the unified `queryChannelStates` / `saveChannelQueries`. The deprecated methods stay overridable so downstream subclasses keep working unchanged. + 🐞 Fixed - Fixed an unhandled `WebSocketChannelException` surfacing when a reconnect attempt failed (e.g. DNS lookup failed in background); the duplicate signal on `sink.done` is now ignored since the stream's `onError` already handles it. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 5bbe685d81..2f99bca949 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -7,6 +7,7 @@ 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/query_channels_result.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'; @@ -646,12 +647,58 @@ class StreamChatClient { }); } - final _queryChannelsCache = InFlightCache>(); + final _queryChannelsCache = InFlightCache(); /// Requests channels with a given query. + /// + /// Either an inline [filter]/[channelStateSort] pair or a [predefinedFilter] + /// identifier (optionally interpolated with [filterValues] and [sortValues]) + /// can be supplied. + /// + /// Use [queryChannelsWithResult] if you also need the server-resolved + /// [PredefinedFilter] spec. Stream> queryChannels({ Filter? filter, SortOrder? channelStateSort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + PaginationParams paginationParams = const PaginationParams(), + bool waitForConnect = true, + }) => + queryChannelsWithResult( + filter: filter, + channelStateSort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + waitForConnect: waitForConnect, + ).map((result) => result.channels); + + /// Requests channels with a given query, yielding a [QueryChannelsResult] + /// that carries both the live channel list and the server-resolved + /// [PredefinedFilter] spec (when one is associated with the query). + /// + /// Yields the offline-cached result first (when available), followed by + /// the online result. Concurrent identical online queries are coalesced + /// via [_queryChannelsCache]. + Stream queryChannelsWithResult({ + Filter? filter, + SortOrder? channelStateSort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, bool state = true, bool watch = true, bool presence = false, @@ -668,6 +715,9 @@ class StreamChatClient { final hash = generateHash([ filter, channelStateSort, + predefinedFilter, + filterValues, + sortValues, state, watch, presence, @@ -677,15 +727,18 @@ class StreamChatClient { ]); // Per-caller offline emit — local persistence, not coalesced. - var offlineChannels = []; + QueryChannelsResult? offlineResult; try { - offlineChannels = await queryChannelsOffline( + offlineResult = await _queryChannelsOfflineImpl( filter: filter, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, channelStateSort: channelStateSort, paginationParams: paginationParams, ); - if (offlineChannels.isNotEmpty) yield offlineChannels; + if (offlineResult.channels.isNotEmpty) yield offlineResult; } catch (e, stk) { logger.warning('Error querying channels offline', e, stk); // Continue to online query even if offline fails @@ -697,9 +750,12 @@ class StreamChatClient { // the lifecycle details. final result = await _queryChannelsCache.run( hash, - () => queryChannelsOnline( + () => _queryChannelsOnlineImpl( filter: filter, sort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, state: state, watch: watch, presence: presence, @@ -719,7 +775,7 @@ class StreamChatClient { } catch (e, stk) { logger.severe('Error querying channels online', e, stk); // Only rethrow if we have no channels to show the user - if (offlineChannels.isEmpty) rethrow; + if (offlineResult == null || offlineResult.channels.isEmpty) rethrow; } } @@ -748,6 +804,40 @@ class StreamChatClient { Future> queryChannelsOnline({ Filter? filter, SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + bool state = true, + bool watch = true, + bool presence = false, + int? memberLimit, + int? messageLimit, + bool waitForConnect = true, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final result = await _queryChannelsOnlineImpl( + filter: filter, + sort: sort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + waitForConnect: waitForConnect, + paginationParams: paginationParams, + ); + return result.channels; + } + + Future _queryChannelsOnlineImpl({ + Filter? filter, + SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, bool state = true, bool watch = true, bool presence = false, @@ -778,6 +868,9 @@ class StreamChatClient { final res = await _chatApi.channel.queryChannels( filter: filter, sort: sort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, state: state, watch: watch, presence: presence, @@ -792,7 +885,10 @@ class StreamChatClient { Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart '''); - return []; + return QueryChannelsResult( + channels: const [], + predefinedFilter: res.predefinedFilter, + ); } final channels = res.channels; @@ -810,32 +906,88 @@ class StreamChatClient { // Submit delivery report for the channels fetched in this query. await channelDeliveryReporter.submitForDelivery(updateData.value); - await chatPersistenceClient?.updateChannelQueries( - filter, - channels.map((c) => c.channel!.cid).toList(), - // Clear the query cache if we are refreshing. - clearQueryCache: (paginationParams.offset ?? 0) == 0, + final cachedCids = channels.map((c) => c.channel!.cid).toList(); + // Clear the query cache if we are refreshing. + final clearQueryCache = (paginationParams.offset ?? 0) == 0; + + Filter? resolvedFilter; + SortOrder? resolvedSort; + if (res.predefinedFilter case final resolvedPredefinedFilter?) { + resolvedFilter = resolvedPredefinedFilter.filter; + resolvedSort = resolvedPredefinedFilter.effectiveSort; + } + + await chatPersistenceClient?.saveChannelQueries( + cids: cachedCids, + filter: filter, + sort: sort, + predefinedFilter: predefinedFilter, + resolvedFilter: resolvedFilter, + resolvedSort: resolvedSort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: clearQueryCache, ); this.state.addChannels(updateData.key); - return updateData.value; + return QueryChannelsResult( + channels: updateData.value, + predefinedFilter: res.predefinedFilter, + ); } /// Requests channels with a given query from the Persistence client. Future> queryChannelsOffline({ Filter? filter, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + SortOrder? channelStateSort, + PaginationParams paginationParams = const PaginationParams(), + }) async { + final result = await _queryChannelsOfflineImpl( + filter: filter, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + channelStateSort: channelStateSort, + paginationParams: paginationParams, + ); + return result.channels; + } + + Future _queryChannelsOfflineImpl({ + Filter? filter, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, SortOrder? channelStateSort, PaginationParams paginationParams = const PaginationParams(), }) async { - final offlineChannels = (await chatPersistenceClient?.getChannelStates( + final res = await chatPersistenceClient?.queryChannelStates( filter: filter, - channelStateSort: channelStateSort, + sort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, paginationParams: paginationParams, - )) ?? - []; - final updatedData = _mapChannelStateToChannel(offlineChannels); - state.addChannels(updatedData.key); - return updatedData.value; + ) ?? + (QueryChannelsResponse()..channels = const []); + + if (res.channels.isEmpty) { + logger.info('No channels found in offline storage for the given query'); + return QueryChannelsResult( + channels: const [], + predefinedFilter: res.predefinedFilter, + ); + } + + final updateData = _mapChannelStateToChannel(res.channels); + state.addChannels(updateData.key); + return QueryChannelsResult( + channels: updateData.value, + predefinedFilter: res.predefinedFilter, + ); } MapEntry, List> _mapChannelStateToChannel( diff --git a/packages/stream_chat/lib/src/client/query_channels_result.dart b/packages/stream_chat/lib/src/client/query_channels_result.dart new file mode 100644 index 0000000000..f8d967e60c --- /dev/null +++ b/packages/stream_chat/lib/src/client/query_channels_result.dart @@ -0,0 +1,19 @@ +import 'package:stream_chat/src/client/channel.dart'; +import 'package:stream_chat/src/core/models/predefined_filter.dart'; + +/// The result of a `queryChannelsWithResult` call on [StreamChatClient]. +/// +/// Carries the live [Channel] instances matching the query alongside the +/// server-resolved [PredefinedFilter] spec (when one is associated with the +/// query). +class QueryChannelsResult { + /// Creates a new [QueryChannelsResult]. + const QueryChannelsResult({required this.channels, this.predefinedFilter}); + + /// The live [Channel] instances matching the query. + final List channels; + + /// The server-resolved predefined-filter spec, or null when the query did + /// not use a `predefinedFilter`. + final PredefinedFilter? predefinedFilter; +} 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 77c2425a55..45149c5422 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -50,9 +50,17 @@ class ChannelApi { } /// Requests channels with a given query from the API. + /// + /// Either an inline [filter]/[sort] pair or a [predefinedFilter] identifier + /// (with optional [filterValues] / [sortValues]) may be provided. When a + /// predefined filter is used, the server resolves it and echoes the + /// materialized filter/sort on [QueryChannelsResponse.predefinedFilter]. Future queryChannels({ Filter? filter, SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, int? memberLimit, int? messageLimit, bool state = true, @@ -72,6 +80,9 @@ class ChannelApi { // passed options if (sort != null) 'sort': sort, if (filter != null) 'filter_conditions': filter, + if (predefinedFilter != null) 'predefined_filter': predefinedFilter, + if (filterValues != null) 'filter_values': filterValues, + if (sortValues != null) 'sort_values': sortValues, if (memberLimit != null) 'member_limit': memberLimit, if (messageLimit != null) 'message_limit': messageLimit, diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index e1d0537eec..de5e35ada8 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -15,6 +15,7 @@ import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/poll.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; import 'package:stream_chat/src/core/models/poll_vote.dart'; +import 'package:stream_chat/src/core/models/predefined_filter.dart'; import 'package:stream_chat/src/core/models/push_preference.dart'; import 'package:stream_chat/src/core/models/reaction.dart'; import 'package:stream_chat/src/core/models/read.dart'; @@ -78,6 +79,13 @@ class QueryChannelsResponse extends _BaseResponse { @JsonKey(defaultValue: []) late List channels; + /// Predefined filter spec as resolved by the server. + /// + /// Populated when the request used `predefined_filter`. Contains the + /// preset name and the materialized `filter`/`sort` that were applied. + @JsonKey(name: 'predefined_filter') + PredefinedFilter? predefinedFilter; + /// Create a new instance from a json static QueryChannelsResponse fromJson(Map json) => _$QueryChannelsResponseFromJson(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 58b5619e0a..e9c6394365 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -31,22 +31,30 @@ SyncResponse _$SyncResponseFromJson(Map json) => SyncResponse() []; QueryChannelsResponse _$QueryChannelsResponseFromJson( - Map json) => + Map json, +) => QueryChannelsResponse() ..duration = json['duration'] as String? ..channels = (json['channels'] as List?) ?.map((e) => ChannelState.fromJson(e as Map)) .toList() ?? - []; + [] + ..predefinedFilter = json['predefined_filter'] == null + ? null + : PredefinedFilter.fromJson( + json['predefined_filter'] as Map, + ); TranslateMessageResponse _$TranslateMessageResponseFromJson( - Map json) => + Map json, +) => TranslateMessageResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map); QueryMembersResponse _$QueryMembersResponseFromJson( - Map json) => + Map json, +) => QueryMembersResponse() ..duration = json['duration'] as String? ..members = (json['members'] as List?) @@ -55,11 +63,13 @@ QueryMembersResponse _$QueryMembersResponseFromJson( []; PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson( - Map json) => + Map json, +) => PartialUpdateMemberResponse() ..duration = json['duration'] as String? - ..channelMember = - Member.fromJson(json['channel_member'] as Map); + ..channelMember = Member.fromJson( + json['channel_member'] as Map, + ); QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => QueryUsersResponse() @@ -70,7 +80,8 @@ QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => []; QueryBannedUsersResponse _$QueryBannedUsersResponseFromJson( - Map json) => + Map json, +) => QueryBannedUsersResponse() ..duration = json['duration'] as String? ..bans = (json['bans'] as List?) @@ -79,7 +90,8 @@ QueryBannedUsersResponse _$QueryBannedUsersResponseFromJson( []; QueryReactionsResponse _$QueryReactionsResponseFromJson( - Map json) => + Map json, +) => QueryReactionsResponse() ..duration = json['duration'] as String? ..reactions = (json['reactions'] as List?) @@ -88,7 +100,8 @@ QueryReactionsResponse _$QueryReactionsResponseFromJson( []; QueryRepliesResponse _$QueryRepliesResponseFromJson( - Map json) => + Map json, +) => QueryRepliesResponse() ..duration = json['duration'] as String? ..messages = (json['messages'] as List?) @@ -105,7 +118,8 @@ ListDevicesResponse _$ListDevicesResponseFromJson(Map json) => []; SendAttachmentResponse _$SendAttachmentResponseFromJson( - Map json) => + Map json, +) => SendAttachmentResponse() ..duration = json['duration'] as String? ..file = json['file'] as String?; @@ -117,14 +131,16 @@ SendFileResponse _$SendFileResponseFromJson(Map json) => ..thumbUrl = json['thumb_url'] as String?; SendReactionResponse _$SendReactionResponseFromJson( - Map json) => + Map json, +) => SendReactionResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map) ..reaction = Reaction.fromJson(json['reaction'] as Map); ConnectGuestUserResponse _$ConnectGuestUserResponseFromJson( - Map json) => + Map json, +) => ConnectGuestUserResponse() ..duration = json['duration'] as String? ..accessToken = json['access_token'] as String @@ -139,7 +155,8 @@ UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => {}; UpdateMessageResponse _$UpdateMessageResponseFromJson( - Map json) => + Map json, +) => UpdateMessageResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map); @@ -158,7 +175,8 @@ GetMessageResponse _$GetMessageResponseFromJson(Map json) => : ChannelModel.fromJson(json['channel'] as Map); SearchMessagesResponse _$SearchMessagesResponseFromJson( - Map json) => + Map json, +) => SearchMessagesResponse() ..duration = json['duration'] as String? ..results = (json['results'] as List?) @@ -170,7 +188,8 @@ SearchMessagesResponse _$SearchMessagesResponseFromJson( ..previous = json['previous'] as String?; GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson( - Map json) => + Map json, +) => GetMessagesByIdResponse() ..duration = json['duration'] as String? ..messages = (json['messages'] as List?) @@ -179,7 +198,8 @@ GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson( []; UpdateChannelResponse _$UpdateChannelResponseFromJson( - Map json) => + Map json, +) => UpdateChannelResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) @@ -191,7 +211,8 @@ UpdateChannelResponse _$UpdateChannelResponseFromJson( : Message.fromJson(json['message'] as Map); PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson( - Map json) => + Map json, +) => PartialUpdateChannelResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) @@ -200,7 +221,8 @@ PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson( .toList(); InviteMembersResponse _$InviteMembersResponseFromJson( - Map json) => + Map json, +) => InviteMembersResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) @@ -213,7 +235,8 @@ InviteMembersResponse _$InviteMembersResponseFromJson( : Message.fromJson(json['message'] as Map); RemoveMembersResponse _$RemoveMembersResponseFromJson( - Map json) => + Map json, +) => RemoveMembersResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) @@ -245,7 +268,8 @@ AddMembersResponse _$AddMembersResponseFromJson(Map json) => : Message.fromJson(json['message'] as Map); AcceptInviteResponse _$AcceptInviteResponseFromJson( - Map json) => + Map json, +) => AcceptInviteResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) @@ -258,7 +282,8 @@ AcceptInviteResponse _$AcceptInviteResponseFromJson( : Message.fromJson(json['message'] as Map); RejectInviteResponse _$RejectInviteResponseFromJson( - Map json) => + Map json, +) => RejectInviteResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) @@ -274,7 +299,8 @@ EmptyResponse _$EmptyResponseFromJson(Map json) => EmptyResponse()..duration = json['duration'] as String?; ChannelStateResponse _$ChannelStateResponseFromJson( - Map json) => + Map json, +) => ChannelStateResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) @@ -293,7 +319,8 @@ ChannelStateResponse _$ChannelStateResponseFromJson( []; OGAttachmentResponse _$OGAttachmentResponseFromJson( - Map json) => + Map json, +) => OGAttachmentResponse() ..duration = json['duration'] as String? ..ogScrapeUrl = json['og_scrape_url'] as String @@ -329,7 +356,8 @@ UserBlockResponse _$UserBlockResponseFromJson(Map json) => ..createdAt = DateTime.parse(json['created_at'] as String); BlockedUsersResponse _$BlockedUsersResponseFromJson( - Map json) => + Map json, +) => BlockedUsersResponse() ..duration = json['duration'] as String? ..blocks = (json['blocks'] as List?) @@ -353,34 +381,42 @@ UpdatePollResponse _$UpdatePollResponseFromJson(Map json) => ..poll = Poll.fromJson(json['poll'] as Map); CreatePollOptionResponse _$CreatePollOptionResponseFromJson( - Map json) => + Map json, +) => CreatePollOptionResponse() ..duration = json['duration'] as String? - ..pollOption = - PollOption.fromJson(json['poll_option'] as Map); + ..pollOption = PollOption.fromJson( + json['poll_option'] as Map, + ); GetPollOptionResponse _$GetPollOptionResponseFromJson( - Map json) => + Map json, +) => GetPollOptionResponse() ..duration = json['duration'] as String? - ..pollOption = - PollOption.fromJson(json['poll_option'] as Map); + ..pollOption = PollOption.fromJson( + json['poll_option'] as Map, + ); UpdatePollOptionResponse _$UpdatePollOptionResponseFromJson( - Map json) => + Map json, +) => UpdatePollOptionResponse() ..duration = json['duration'] as String? - ..pollOption = - PollOption.fromJson(json['poll_option'] as Map); + ..pollOption = PollOption.fromJson( + json['poll_option'] as Map, + ); CastPollVoteResponse _$CastPollVoteResponseFromJson( - Map json) => + Map json, +) => CastPollVoteResponse() ..duration = json['duration'] as String? ..vote = PollVote.fromJson(json['vote'] as Map); RemovePollVoteResponse _$RemovePollVoteResponseFromJson( - Map json) => + Map json, +) => RemovePollVoteResponse() ..duration = json['duration'] as String? ..vote = PollVote.fromJson(json['vote'] as Map); @@ -395,7 +431,8 @@ QueryPollsResponse _$QueryPollsResponseFromJson(Map json) => ..next = json['next'] as String?; QueryPollVotesResponse _$QueryPollVotesResponseFromJson( - Map json) => + Map json, +) => QueryPollVotesResponse() ..duration = json['duration'] as String? ..votes = (json['votes'] as List?) @@ -410,13 +447,15 @@ GetThreadResponse _$GetThreadResponseFromJson(Map json) => ..thread = Thread.fromJson(json['thread'] as Map); UpdateThreadResponse _$UpdateThreadResponseFromJson( - Map json) => + Map json, +) => UpdateThreadResponse() ..duration = json['duration'] as String? ..thread = Thread.fromJson(json['thread'] as Map); QueryThreadsResponse _$QueryThreadsResponseFromJson( - Map json) => + Map json, +) => QueryThreadsResponse() ..duration = json['duration'] as String? ..threads = (json['threads'] as List?) @@ -445,21 +484,26 @@ QueryDraftsResponse _$QueryDraftsResponseFromJson(Map json) => ..next = json['next'] as String?; CreateReminderResponse _$CreateReminderResponseFromJson( - Map json) => + Map json, +) => CreateReminderResponse() ..duration = json['duration'] as String? - ..reminder = - MessageReminder.fromJson(json['reminder'] as Map); + ..reminder = MessageReminder.fromJson( + json['reminder'] as Map, + ); UpdateReminderResponse _$UpdateReminderResponseFromJson( - Map json) => + Map json, +) => UpdateReminderResponse() ..duration = json['duration'] as String? - ..reminder = - MessageReminder.fromJson(json['reminder'] as Map); + ..reminder = MessageReminder.fromJson( + json['reminder'] as Map, + ); QueryRemindersResponse _$QueryRemindersResponseFromJson( - Map json) => + Map json, +) => QueryRemindersResponse() ..duration = json['duration'] as String? ..reminders = (json['reminders'] as List?) @@ -469,7 +513,8 @@ QueryRemindersResponse _$QueryRemindersResponseFromJson( ..next = json['next'] as String?; GetUnreadCountResponse _$GetUnreadCountResponseFromJson( - Map json) => + Map json, +) => GetUnreadCountResponse() ..duration = json['duration'] as String? ..totalUnreadCount = (json['total_unread_count'] as num).toInt() @@ -491,20 +536,23 @@ GetUnreadCountResponse _$GetUnreadCountResponseFromJson( .toList(); UpsertPushPreferencesResponse _$UpsertPushPreferencesResponseFromJson( - Map json) => + Map json, +) => UpsertPushPreferencesResponse() ..duration = json['duration'] as String? ..userPreferences = _userPreferencesFromJson( - json['user_preferences'] as Map?) + json['user_preferences'] as Map?, + ) ..userChannelPreferences = (json['user_channel_preferences'] as Map?)?.map( (k, e) => MapEntry( - k, - (e as Map).map( - (k, e) => MapEntry( - k, - ChannelPushPreference.fromJson( - e as Map)), - )), + k, + (e as Map).map( + (k, e) => MapEntry( + k, + ChannelPushPreference.fromJson(e as Map), + ), + ), + ), ) ?? {}; diff --git a/packages/stream_chat/lib/src/core/models/predefined_filter.dart b/packages/stream_chat/lib/src/core/models/predefined_filter.dart new file mode 100644 index 0000000000..439599c6a2 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/predefined_filter.dart @@ -0,0 +1,82 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; + +part 'predefined_filter.g.dart'; + +/// The resolved predefined filter spec returned by the server. +/// +/// When `predefined_filter` is provided on a `queryChannels` request, the +/// server resolves the template (interpolating any `filter_values` and +/// `sort_values`) and echoes the materialized `filter` and `sort` on the +/// response under this key. +@JsonSerializable(createToJson: false) +class PredefinedFilter { + /// Creates a new instance. + const PredefinedFilter({required this.name, required this.filter, this.sort}); + + /// Create a new instance from a json. + factory PredefinedFilter.fromJson(Map json) => + _$PredefinedFilterFromJson(json); + + /// Identifier of the predefined filter on the server. + final String name; + + /// Filter conditions as resolved by the server. + /// + /// Wrapped in [Filter.raw] — the SDK does not evaluate filters locally. + /// Access the underlying map via [Filter.value] or [Filter.toJson]. + @JsonKey(fromJson: _filterFromJson) + final Filter filter; + + /// Sort specification as resolved by the server. + final SortOrder? sort; + + /// Sort to apply locally, matching what the server applies for this + /// predefined filter — the echoed [sort], or a default derived from + /// [filter] when [sort] is null. + SortOrder get effectiveSort => sort ?? _defaultSortFor(filter); + + static Filter _filterFromJson(Map json) => + Filter.raw(value: json); +} + +SortOrder _defaultSortFor(Filter filter) { + if (_touchesField(filter, ChannelSortKey.lastMessageAt)) { + return const [SortOption.desc(ChannelSortKey.lastMessageAt)]; + } + return const [SortOption.desc(ChannelSortKey.lastUpdated)]; +} + +bool _touchesField(Filter filter, String field) { + if (filter.key == field) return true; + final value = filter.value; + if (value is List) { + return value.any((sub) => _touchesField(sub, field)); + } + if (value is Map) { + return _mapTouchesField(value, field); + } + return false; +} + +bool _mapTouchesField(Map map, String field) { + for (final entry in map.entries) { + final key = entry.key; + if (!key.startsWith(r'$')) { + if (key == field) return true; + continue; + } + // Group operator like $or / $and / $nor — recurse into list items. + final value = entry.value; + if (value is List) { + for (final item in value) { + if (item is Map && _mapTouchesField(item, field)) { + return true; + } + } + } + } + return false; +} diff --git a/packages/stream_chat/lib/src/core/models/predefined_filter.g.dart b/packages/stream_chat/lib/src/core/models/predefined_filter.g.dart new file mode 100644 index 0000000000..0049067005 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/predefined_filter.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'predefined_filter.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PredefinedFilter _$PredefinedFilterFromJson(Map json) => + PredefinedFilter( + name: json['name'] as String, + filter: PredefinedFilter._filterFromJson( + json['filter'] as Map, + ), + sort: (json['sort'] as List?) + ?.map( + (e) => SortOption.fromJson(e as Map), + ) + .toList(), + ); diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 80fe638758..2c3c687b94 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/api/sort_order.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; import 'package:stream_chat/src/core/models/channel_model.dart'; @@ -121,6 +122,7 @@ abstract class ChatPersistenceClient { /// /// Optionally, pass [filter], [sort], [paginationParams] /// for filtering out states. + @Deprecated('Use queryChannelStates instead') Future> getChannelStates({ Filter? filter, SortOrder? channelStateSort, @@ -131,12 +133,92 @@ abstract class ChatPersistenceClient { /// /// If [clearQueryCache] is true before the insert /// the list of matching rows will be deleted + @Deprecated('Use saveChannelQueries instead') Future updateChannelQueries( Filter? filter, List cids, { bool clearQueryCache = false, }); + /// Returns the stored response for a channel query. + /// + /// The method supports two query modes, selected by whether + /// [predefinedFilter] is null: + /// + /// **Standard filter mode** (`predefinedFilter == null`): + /// - [filter] — the runtime filter used to identify the cached query. + /// - [sort] — the sort order applied to the cached channel states. + /// + /// **Predefined filter mode** (`predefinedFilter != null`): + /// - [predefinedFilter] — the server-side filter template name. + /// - [filterValues] / [sortValues] — interpolation maps that, together + /// with the template name, identify the cached query. + /// - The returned [QueryChannelsResponse.predefinedFilter] carries the + /// server-resolved filter + sort spec persisted on the last online + /// query, so the caller can apply the same order the server applied. + /// + /// Both modes: + /// - [paginationParams] paginates results. + /// + /// For standard mode, [QueryChannelsResponse.predefinedFilter] is null. + Future queryChannelStates({ + Filter? filter, + SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + PaginationParams? paginationParams, + }) async { + if (predefinedFilter != null) { + return QueryChannelsResponse()..channels = const []; + } + // ignore: deprecated_member_use_from_same_package + final channels = await getChannelStates( + filter: filter, + channelStateSort: sort, + paginationParams: paginationParams, + ); + return QueryChannelsResponse()..channels = channels; + } + + /// Persists the result of a channel query. + /// + /// The method supports two query modes, selected by whether + /// [predefinedFilter] is null. [cids] (the channel ids returned by the + /// query) and [clearQueryCache] (which deletes prior cached rows for the + /// same query before insert) apply to both modes. + /// + /// **Standard filter mode** (`predefinedFilter == null`): + /// - [filter] — the runtime filter under which the [cids] are recorded. + /// - [sort] — the runtime sort order. [resolvedFilter] / [resolvedSort] + /// are ignored in this mode. + /// + /// **Predefined filter mode** (`predefinedFilter != null`): + /// - [predefinedFilter] — the server-side filter template name. + /// - [filterValues] / [sortValues] — interpolation maps used together + /// with the template name to key the cache. + /// - [resolvedFilter] / [resolvedSort] — the server-resolved spec + /// returned in the query response. Persisted alongside [cids] so + /// subsequent offline reads can reconstruct the same filter and + /// order. [filter] / [sort] are ignored in this mode. + Future saveChannelQueries({ + required List cids, + Filter? filter, + SortOrder? sort, + String? predefinedFilter, + Filter? resolvedFilter, + SortOrder? resolvedSort, + Map? filterValues, + Map? sortValues, + bool clearQueryCache = false, + }) async { + if (predefinedFilter != null) { + return; + } + // ignore: deprecated_member_use_from_same_package + return updateChannelQueries(filter, cids, clearQueryCache: clearQueryCache); + } + /// Remove a message by [messageId] Future deleteMessageById(String messageId) => deleteMessageByIds([messageId]); diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 188f784120..6bd2208833 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -20,6 +20,7 @@ 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/query_channels_result.dart'; export 'src/client/retry_policy.dart'; export 'src/core/api/attachment_file_uploader.dart'; export 'src/core/api/requests.dart'; @@ -56,6 +57,7 @@ export 'src/core/models/poll.dart'; export 'src/core/models/poll_option.dart'; export 'src/core/models/poll_vote.dart'; export 'src/core/models/poll_voting_mode.dart'; +export 'src/core/models/predefined_filter.dart'; export 'src/core/models/privacy_settings.dart'; export 'src/core/models/push_preference.dart'; export 'src/core/models/reaction.dart'; diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 5e26d53df1..9040449f89 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -498,6 +498,7 @@ void main() { registerFallbackValue(FakeEvent()); registerFallbackValue(const PaginationParams()); registerFallbackValue(FakeChannelState()); + registerFallbackValue(const Filter.empty()); }); setUp(() async { @@ -612,11 +613,15 @@ void main() { ), ); - when(() => persistence.getChannelStates( + when(() => persistence.queryChannelStates( filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), paginationParams: any(named: 'paginationParams'), - )).thenAnswer((_) async => persistentChannelStates); + )).thenAnswer((_) async => QueryChannelsResponse() + ..channels = persistentChannelStates); final channelStates = List.generate( 3, @@ -651,9 +656,17 @@ void main() { .thenAnswer((_) async {}); when(() => persistence.updateChannelThreads(any(), any())) .thenAnswer((_) async {}); - when(() => persistence.updateChannelQueries(any(), any(), - clearQueryCache: any(named: 'clearQueryCache'))) - .thenAnswer((_) => Future.value()); + when(() => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + )).thenAnswer((_) => Future.value()); // setUp's `connectUser` schedules debounced persistence writes // (1s window) that would otherwise fire during this test's wait @@ -676,9 +689,12 @@ void main() { // invocations have fired before we verify counts. await delay(1500); - verify(() => persistence.getChannelStates( + verify(() => persistence.queryChannelStates( filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), paginationParams: any(named: 'paginationParams'), )).called(1); @@ -699,8 +715,17 @@ void main() { .called(channelStates.length); verify(() => persistence.updateChannelThreads(any(), any())) .called(channelStates.length); - verify(() => persistence.updateChannelQueries(any(), any(), - clearQueryCache: any(named: 'clearQueryCache'))).called(1); + verify(() => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + )).called(1); }, ); @@ -714,11 +739,15 @@ void main() { ), ); - when(() => persistence.getChannelStates( + when(() => persistence.queryChannelStates( filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), paginationParams: any(named: 'paginationParams'), - )).thenAnswer((_) async => persistentChannelStates); + )).thenAnswer((_) async => QueryChannelsResponse() + ..channels = persistentChannelStates); when(() => api.channel.queryChannels( filter: any(named: 'filter'), @@ -764,9 +793,12 @@ void main() { // invocations have fired before we verify counts. await delay(1500); - verify(() => persistence.getChannelStates( + verify(() => persistence.queryChannelStates( filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), paginationParams: any(named: 'paginationParams'), )).called(1); @@ -789,6 +821,358 @@ void main() { .called(persistentChannelStates.length); }, ); + + test( + 'queryChannelsOnline with inline filter persists via ' + 'saveChannelQueries', + () async { + final filter = Filter.in_('members', const ['test-user-id']); + + final channelStates = List.generate( + 3, + (i) => ChannelState( + channel: ChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when(() => api.channel.queryChannels( + filter: filter, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = channelStates, + ); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())) + .thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())) + .thenAnswer((_) async {}); + when(() => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + )).thenAnswer((_) => Future.value()); + + await delay(1100); + clearInteractions(persistence); + + await client.queryChannelsOnline(filter: filter); + + // The standard path passes filter (the inline filter) and a null + // predefinedFilter. resolvedFilter / resolvedSort stay null — + // they're only meaningful for the predefined-filter path. + verify(() => persistence.saveChannelQueries( + cids: channelStates.map((s) => s.channel!.cid).toList(), + filter: filter, + sort: null, + predefinedFilter: null, + resolvedFilter: null, + resolvedSort: null, + filterValues: null, + sortValues: null, + clearQueryCache: true, + )).called(1); + }, + ); + + test( + 'queryChannelsOnline with predefined filter persists via ' + 'saveChannelQueries with resolved sort', + () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_at': true}; + + final channelStates = List.generate( + 3, + (i) => ChannelState( + channel: ChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when(() => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse() + ..channels = channelStates + ..predefinedFilter = const PredefinedFilter( + name: filterName, + filter: Filter.empty(), + sort: [SortOption.desc('last_message_at')], + ), + ); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())) + .thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())) + .thenAnswer((_) async {}); + when(() => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + )).thenAnswer((_) => Future.value()); + + await delay(1100); + clearInteractions(persistence); + + await client.queryChannelsOnline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + verify(() => persistence.saveChannelQueries( + cids: channelStates.map((s) => s.channel!.cid).toList(), + filter: null, + sort: null, + predefinedFilter: filterName, + resolvedFilter: const Filter.empty(), + resolvedSort: const [ + SortOption.desc('last_message_at'), + ], + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + )).called(1); + }, + ); + + test( + 'queryChannelsOffline with predefined filter reads via ' + 'queryChannelStates', + () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_at': true}; + + final channelStates = List.generate( + 3, + (i) => ChannelState( + channel: ChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when(() => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + paginationParams: any(named: 'paginationParams'), + )).thenAnswer((_) async => QueryChannelsResponse() + ..channels = channelStates); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())) + .thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())) + .thenAnswer((_) async {}); + + await delay(1100); + clearInteractions(persistence); + + final channels = await client.queryChannelsOffline( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(channels, hasLength(channelStates.length)); + + verify(() => persistence.queryChannelStates( + filter: null, + sort: null, + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + paginationParams: const PaginationParams(), + )).called(1); + }, + ); + + test( + 'queryChannelsWithResult yields QueryChannelsResult with ' + 'predefinedFilter=null for inline filter', + () async { + final channelStates = List.generate( + 2, + (i) => ChannelState( + channel: ChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + when(() => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = const [], + ); + + when(() => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = channelStates, + ); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())) + .thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())) + .thenAnswer((_) async {}); + when(() => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + )).thenAnswer((_) => Future.value()); + + await delay(1100); + clearInteractions(persistence); + + final results = await client.queryChannelsWithResult().toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNull); + }, + ); + + test( + 'queryChannelsWithResult yields QueryChannelsResult with ' + 'predefinedFilter populated for predefined query', + () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_at': true}; + + final channelStates = List.generate( + 2, + (i) => ChannelState( + channel: ChannelModel(cid: 'test-type-$i:test-id-$i'), + ), + ); + + const resolvedSort = [ + SortOption.desc('last_message_at'), + ]; + const resolvedFilter = Filter.empty(); + const expectedPredefinedFilter = PredefinedFilter( + name: filterName, + filter: resolvedFilter, + sort: resolvedSort, + ); + + when(() => persistence.queryChannelStates( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse()..channels = const [], + ); + + when(() => api.channel.queryChannels( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + )).thenAnswer( + (_) async => QueryChannelsResponse() + ..channels = channelStates + ..predefinedFilter = expectedPredefinedFilter, + ); + + when(() => persistence.getChannelThreads(any())) + .thenAnswer((_) async => >{}); + when(() => persistence.updateChannelState(any())) + .thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())) + .thenAnswer((_) async {}); + when(() => persistence.saveChannelQueries( + cids: any(named: 'cids'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + predefinedFilter: any(named: 'predefinedFilter'), + resolvedFilter: any(named: 'resolvedFilter'), + resolvedSort: any(named: 'resolvedSort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + )).thenAnswer((_) => Future.value()); + + await delay(1100); + clearInteractions(persistence); + + final results = await client + .queryChannelsWithResult( + predefinedFilter: filterName, + filterValues: filterValues, + sortValues: sortValues, + ) + .toList(); + + // Persistence returned empty, so only the online emission is yielded. + expect(results, hasLength(1)); + expect(results.single.channels, hasLength(channelStates.length)); + expect(results.single.predefinedFilter, isNotNull); + expect(results.single.predefinedFilter!.name, equals(filterName)); + expect(results.single.predefinedFilter!.sort, equals(resolvedSort)); + }, + ); }); test('`.disconnectUser` should reset state and user', () async { 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 ae5a0dba81..fca96491ac 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 @@ -169,7 +169,89 @@ void main() { expect(res.channels, isNotEmpty); verify( - () => client.get(path, queryParameters: any(named: 'queryParameters')), + () => client.get(path, queryParameters: {'payload': payload}), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('queryChannels with predefined filter', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + const predefinedFilter = 'sample-app-list'; + const filterValues = {'user_id': 'test-user-id'}; + const sortValues = {'pinned_first': true}; + + const path = '/channels'; + + final channelState = _generateChannelState(channelId, channelType); + + final payload = jsonEncode({ + // default options + 'state': true, + 'watch': true, + 'presence': false, + + // passed options + 'predefined_filter': predefinedFilter, + 'filter_values': filterValues, + 'sort_values': sortValues, + + // pagination + ...const PaginationParams().toJson() + }); + + final resolvedSort = [ + {'field': 'last_message_at', 'direction': -1}, + ]; + + when(() => client.get( + path, + queryParameters: { + 'payload': payload, + }, + )).thenAnswer((_) async => successResponse( + path, + data: { + 'channels': [channelState.toJson()], + 'predefined_filter': { + 'name': predefinedFilter, + 'filter': { + 'members': { + r'$in': ['test-user-id'], + }, + }, + 'sort': resolvedSort, + }, + }, + )); + + final res = await channelApi.queryChannels( + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(res, isNotNull); + expect(res.channels, isNotEmpty); + expect(res.predefinedFilter, isNotNull); + expect(res.predefinedFilter!.name, predefinedFilter); + expect( + res.predefinedFilter!.filter, + const Filter.raw( + value: { + 'members': { + r'$in': ['test-user-id'], + }, + }, + ), + ); + expect(res.predefinedFilter!.sort, hasLength(1)); + expect(res.predefinedFilter!.sort!.first.field, 'last_message_at'); + expect(res.predefinedFilter!.sort!.first.direction, SortOption.DESC); + + verify( + () => client.get(path, queryParameters: {'payload': payload}), ).called(1); verifyNoMoreInteractions(client); }); diff --git a/packages/stream_chat/test/src/core/models/predefined_filter_test.dart b/packages/stream_chat/test/src/core/models/predefined_filter_test.dart new file mode 100644 index 0000000000..78821d6f4d --- /dev/null +++ b/packages/stream_chat/test/src/core/models/predefined_filter_test.dart @@ -0,0 +1,155 @@ +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/channel_state.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/predefined_filter.dart'; +import 'package:test/test.dart'; + +void main() { + const filterJson = { + r'$or': [ + { + 'type': {r'$eq': 'messaging'}, + }, + { + r'$and': [ + {'frozen': false}, + { + 'members': { + r'$in': ['user-1', 'user-2'], + }, + }, + ], + }, + ], + }; + + final json = { + 'name': 'unread', + 'filter': filterJson, + 'sort': [ + {'field': 'last_message_at', 'direction': -1}, + ], + }; + + test('PredefinedFilter.fromJson parses all fields', () { + final parsed = PredefinedFilter.fromJson(json); + + expect(parsed.name, 'unread'); + expect(parsed.filter.value, filterJson); + expect(parsed.sort, hasLength(1)); + expect(parsed.sort!.first.field, 'last_message_at'); + expect(parsed.sort!.first.direction, SortOption.DESC); + }); + + group('effectiveSort', () { + test('returns the echoed sort when present', () { + const filter = PredefinedFilter( + name: 'x', + filter: Filter.empty(), + sort: [SortOption.asc(ChannelSortKey.createdAt)], + ); + + final sort = filter.effectiveSort; + + expect(sort, hasLength(1)); + expect(sort.single.field, equals(ChannelSortKey.createdAt)); + expect(sort.single.direction, equals(SortOption.ASC)); + }); + + test( + 'falls back to lastUpdated desc when sort is null and filter is empty', + () { + const predefined = PredefinedFilter(name: 'x', filter: Filter.empty()); + + final sort = predefined.effectiveSort; + + expect(sort, hasLength(1)); + expect(sort.single.field, equals(ChannelSortKey.lastUpdated)); + expect(sort.single.direction, equals(SortOption.DESC)); + }, + ); + + test( + 'falls back to lastMessageAt desc when raw filter ' + 'touches last_message_at', () { + const predefined = PredefinedFilter( + name: 'x', + filter: Filter.raw( + value: { + 'last_message_at': {r'$gt': '2024-01-01T00:00:00Z'}, + }, + ), + ); + + final sort = predefined.effectiveSort; + + expect(sort.single.field, equals(ChannelSortKey.lastMessageAt)); + expect(sort.single.direction, equals(SortOption.DESC)); + }); + + test( + 'falls back to lastMessageAt desc when last_message_at ' + r'is nested under $or', () { + const predefined = PredefinedFilter( + name: 'x', + filter: Filter.raw( + value: { + r'$or': [ + { + 'type': {r'$eq': 'messaging'}, + }, + { + 'last_message_at': {r'$gt': '2024-01-01T00:00:00Z'}, + }, + ], + }, + ), + ); + + final sort = predefined.effectiveSort; + + expect(sort.single.field, equals(ChannelSortKey.lastMessageAt)); + }); + + test( + 'falls back to lastUpdated desc when filter touches only other fields', + () { + const predefined = PredefinedFilter( + name: 'x', + filter: Filter.raw( + value: { + r'$and': [ + {'frozen': false}, + { + 'members': { + r'$in': ['u1', 'u2'], + }, + }, + ], + }, + ), + ); + + final sort = predefined.effectiveSort; + + expect(sort.single.field, equals(ChannelSortKey.lastUpdated)); + }, + ); + + test( + 'falls back to lastMessageAt desc when typed Filter.and ' + 'touches last_message_at', () { + final predefined = PredefinedFilter( + name: 'x', + filter: Filter.and([ + Filter.equal('type', 'messaging'), + Filter.greater('last_message_at', '2024-01-01T00:00:00Z'), + ]), + ); + + final sort = predefined.effectiveSort; + + expect(sort.single.field, equals(ChannelSortKey.lastMessageAt)); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 579466e236..8c90f418c2 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,5 +1,9 @@ ## 9.26.0 +✅ Added + +- Added support for predefined filters on `StreamChannelListController`. + 🐞 Fixed - Fixed a reconnect storm when the OS closed the WebSocket during the background keep-alive window; reconnects are now paused on background and resumed on foreground. diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index db2ff2c4c6..28462517be 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -38,6 +38,15 @@ class StreamChannelListController extends PagedValueNotifier { /// /// * `sort` is the sorting used for the channels matching the filters. /// + /// * `predefinedFilter` is the name of the server-defined filter. If set, it + /// takes precedence over [filter] and [channelStateSort]. + /// + /// `* filterValues` are the values used to interpolate placeholders in the + /// [predefinedFilter] filter definition on the server. + /// + /// * `sortValues` are the values used to interpolate placeholders in the + /// [predefinedFilter] sort definition on the server. + /// /// * `presence` sets whether you'll receive user presence updates via the /// websocket events. /// @@ -51,11 +60,15 @@ class StreamChannelListController extends PagedValueNotifier { StreamChannelListEventHandler? eventHandler, this.filter, this.channelStateSort = defaultChannelListSort, + this.predefinedFilter, + this.filterValues, + this.sortValues, this.presence = true, this.limit = defaultChannelPagedLimit, this.messageLimit, this.memberLimit, }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), + _resolvedChannelStateSort = channelStateSort, super(const PagedValue.loading()); /// Creates a [StreamChannelListController] from the passed [value]. @@ -65,11 +78,15 @@ class StreamChannelListController extends PagedValueNotifier { StreamChannelListEventHandler? eventHandler, this.filter, this.channelStateSort = defaultChannelListSort, + this.predefinedFilter, + this.filterValues, + this.sortValues, this.presence = true, this.limit = defaultChannelPagedLimit, this.messageLimit, this.memberLimit, - }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(); + }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), + _resolvedChannelStateSort = channelStateSort; /// The client to use for the channels list. final StreamChatClient client; @@ -95,6 +112,28 @@ class StreamChannelListController extends PagedValueNotifier { /// Direction can be ascending or descending. final SortOrder? channelStateSort; + /// The sort actually applied to incoming events. Seeded from + /// [channelStateSort] and overwritten whenever a query response carries a + /// resolved [PredefinedFilter.sort], so event-driven inserts keep matching + /// the server-resolved order even when callers only specify + /// [predefinedFilter]. + SortOrder? _resolvedChannelStateSort; + + /// Identifier of a server-side predefined filter to query channels with. + /// + /// When set, the server resolves the preset and returns the materialized + /// channels. [filterValues] and [sortValues] interpolate placeholders in + /// the preset definition. + final String? predefinedFilter; + + /// Values used to interpolate placeholders in the [predefinedFilter] + /// filter definition on the server. + final Map? filterValues; + + /// Values used to interpolate placeholders in the [predefinedFilter] + /// sort definition on the server. + final Map? sortValues; + /// If true you’ll receive user presence updates via the websocket events final bool presence; @@ -110,7 +149,7 @@ class StreamChannelListController extends PagedValueNotifier { @override set value(PagedValue newValue) { - super.value = switch (channelStateSort) { + super.value = switch (_resolvedChannelStateSort) { null => newValue, final channelSort => newValue.maybeMap( orElse: () => newValue, @@ -131,14 +170,19 @@ class StreamChannelListController extends PagedValueNotifier { _kDefaultBackendPaginationLimit, ); try { - await for (final channels in client.queryChannels( + await for (final result in client.queryChannelsWithResult( filter: filter, - channelStateSort: channelStateSort, + channelStateSort: _resolvedChannelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, memberLimit: memberLimit, messageLimit: messageLimit, presence: presence, paginationParams: PaginationParams(limit: limit), )) { + _resolveSort(result); + final channels = result.channels; final nextKey = channels.length < limit ? null : channels.length; value = PagedValue( items: channels, @@ -160,14 +204,18 @@ class StreamChannelListController extends PagedValueNotifier { final previousValue = value.asSuccess; try { - await for (final channels in client.queryChannels( + await for (final result in client.queryChannelsWithResult( filter: filter, - channelStateSort: channelStateSort, + channelStateSort: _resolvedChannelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, memberLimit: memberLimit, messageLimit: messageLimit, presence: presence, paginationParams: PaginationParams(limit: limit, offset: nextPageKey), )) { + final channels = result.channels; final previousItems = previousValue.items; final newItems = previousItems + channels; final nextKey = channels.length < limit ? null : newItems.length; @@ -184,6 +232,20 @@ class StreamChannelListController extends PagedValueNotifier { } } + void _resolveSort(QueryChannelsResult result) { + final predefinedFilter = result.predefinedFilter; + // Update the active sort only when predefinedFilter is present, + // otherwise use the initially set sort. + if (predefinedFilter == null) return; + _resolvedChannelStateSort = predefinedFilter.effectiveSort; + } + + @override + Future refresh({bool resetValue = true}) { + if (resetValue) _resolvedChannelStateSort = channelStateSort; + return super.refresh(resetValue: resetValue); + } + /// Replaces the previously loaded channels with the passed [channels]. set channels(List channels) { if (value.isSuccess) { diff --git a/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart new file mode 100644 index 0000000000..b6438670c2 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_channel_list_controller_test.dart @@ -0,0 +1,293 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_channel_list_controller.dart'; + +import 'mocks.dart'; + +void main() { + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + + final client = MockClient(); + + setUp(() { + when(client.on).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() { + reset(client); + }); + + test('creates with loading state by default', () { + final controller = StreamChannelListController(client: client); + expect(controller.value, isA()); + }); + + test('fromValue preserves the provided value', () { + const value = PagedValue(items: []); + final controller = StreamChannelListController.fromValue( + value, + client: client, + ); + expect(controller.value, same(value)); + }); + + test( + 'doInitialLoad forwards inline filter and sort to queryChannels', + () async { + final filter = Filter.in_('members', const ['u1']); + const sort = [ + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( + (_) => Stream.value(const QueryChannelsResult(channels: [])), + ); + + final controller = StreamChannelListController( + client: client, + filter: filter, + channelStateSort: sort, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + verify( + () => client.queryChannelsWithResult( + filter: filter, + channelStateSort: sort, + predefinedFilter: null, + filterValues: null, + sortValues: null, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }, + ); + + test( + 'doInitialLoad forwards predefinedFilter, filterValues, sortValues to ' + 'queryChannels', + () async { + const presetName = 'sample_app_filter'; + const filterValues = {'user_id': 'u1'}; + const sortValues = {'preset': 'recent'}; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( + (_) => Stream.value(const QueryChannelsResult(channels: [])), + ); + + final controller = StreamChannelListController( + client: client, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + channelStateSort: null, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + verify( + () => client.queryChannelsWithResult( + filter: null, + channelStateSort: null, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }, + ); + + test('doInitialLoad transitions to error state on exception', () async { + final exception = Exception('API unavailable'); + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.error(exception)); + + final controller = StreamChannelListController( + client: client, + channelStateSort: null, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value, isA()); + expect( + (controller.value as Error).error.message, + contains('API unavailable'), + ); + }); + + test('loadMore appends new channels and forwards inline filter', () async { + final filter = Filter.in_('members', const ['u1']); + const nextPageKey = 2; + + final existing = [MockChannel(), MockChannel()]; + final fetched = [MockChannel()]; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.value(QueryChannelsResult(channels: fetched))); + + final controller = StreamChannelListController.fromValue( + PagedValue(items: existing, nextPageKey: nextPageKey), + client: client, + filter: filter, + channelStateSort: null, + ); + + await controller.loadMore(nextPageKey); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, equals([...existing, ...fetched])); + + final captured = verify( + () => client.queryChannelsWithResult( + filter: filter, + channelStateSort: null, + predefinedFilter: null, + filterValues: null, + sortValues: null, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: captureAny(named: 'paginationParams'), + ), + ).captured; + + expect(captured, hasLength(1)); + expect((captured.single as PaginationParams).offset, equals(nextPageKey)); + }); + + test( + 'loadMore appends new channels and forwards predefined params', + () async { + const presetName = 'sample_app_filter'; + const filterValues = {'user_id': 'u1'}; + const sortValues = {'preset': 'recent'}; + const nextPageKey = 2; + + final existing = [MockChannel(), MockChannel()]; + final fetched = [MockChannel()]; + + when( + () => client.queryChannelsWithResult( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + predefinedFilter: any(named: 'predefinedFilter'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) => Stream.value(QueryChannelsResult(channels: fetched))); + + final controller = StreamChannelListController.fromValue( + PagedValue(items: existing, nextPageKey: nextPageKey), + client: client, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + channelStateSort: null, + ); + + await controller.loadMore(nextPageKey); + await pumpEventQueue(); + + expect( + controller.value.asSuccess.items, + equals([...existing, ...fetched]), + ); + + final captured = verify( + () => client.queryChannelsWithResult( + filter: null, + channelStateSort: null, + predefinedFilter: presetName, + filterValues: filterValues, + sortValues: sortValues, + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + presence: any(named: 'presence'), + paginationParams: captureAny(named: 'paginationParams'), + ), + ).captured; + + expect(captured, hasLength(1)); + expect((captured.single as PaginationParams).offset, equals(nextPageKey)); + }, + ); + + test('channels setter replaces items while keeping success state', () { + const initial = PagedValue(items: []); + final controller = StreamChannelListController.fromValue( + initial, + client: client, + channelStateSort: null, + )..channels = const []; + + expect(controller.value.isSuccess, isTrue); + }); +} diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 0bb108bb6e..1c634c73d1 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +✅ Added + +- Added support for predefined filters for `QueryChannels` on `StreamChatPersistenceClient`. + ## 9.26.0 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/converter/converter.dart b/packages/stream_chat_persistence/lib/src/converter/converter.dart index bf8115e4b5..cdd0a2d40d 100644 --- a/packages/stream_chat_persistence/lib/src/converter/converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/converter.dart @@ -1,4 +1,6 @@ +export 'filter_converter.dart'; export 'list_converter.dart'; export 'map_converter.dart'; export 'reaction_groups_converter.dart'; +export 'sort_order_converter.dart'; export 'voting_visibility_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/converter/filter_converter.dart b/packages/stream_chat_persistence/lib/src/converter/filter_converter.dart new file mode 100644 index 0000000000..56857c6470 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/filter_converter.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// A [TypeConverter] that serializes a [Filter] to and from its JSON [String] +/// representation. +/// +/// Used by the `channel_query_metadata` table to persist the server-resolved +/// filter spec associated with a predefined-filter query, so that offline +/// reads can reconstruct the full resolved spec. +class FilterConverter extends TypeConverter { + /// Creates a new instance. + const FilterConverter(); + + @override + Filter fromSql(String fromDb) { + final value = jsonDecode(fromDb) as Map; + return Filter.raw(value: value); + } + + @override + String toSql(Filter value) => jsonEncode(value.toJson()); +} diff --git a/packages/stream_chat_persistence/lib/src/converter/sort_order_converter.dart b/packages/stream_chat_persistence/lib/src/converter/sort_order_converter.dart new file mode 100644 index 0000000000..a1c8be7a89 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/sort_order_converter.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// A [TypeConverter] that serializes a [SortOrder] of [ChannelState] to and +/// from its JSON [String] representation. +/// +/// Used by the `channel_query_metadata` table to persist the server-resolved +/// sort spec associated with a predefined-filter query, so that offline reads +/// can apply the same ordering. +class ChannelStateSortOrderConverter + extends TypeConverter, String> { + /// Creates a new instance. + const ChannelStateSortOrderConverter(); + + @override + SortOrder fromSql(String fromDb) { + final list = jsonDecode(fromDb) as List; + return list + .cast>() + .map(SortOption.fromJson) + .toList(growable: false); + } + + @override + String toSql(SortOrder value) { + return jsonEncode(value.map((o) => o.toJson()).toList()); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart index b0931ee3be..f28d9afb72 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; import 'package:stream_chat_persistence/src/entity/channel_queries.dart'; +import 'package:stream_chat_persistence/src/entity/channel_queries_metadata.dart'; import 'package:stream_chat_persistence/src/entity/channels.dart'; import 'package:stream_chat_persistence/src/entity/users.dart'; import 'package:stream_chat_persistence/src/mapper/mapper.dart'; @@ -11,7 +12,8 @@ import 'package:stream_chat_persistence/src/mapper/mapper.dart'; part 'channel_query_dao.g.dart'; /// The Data Access Object for operations in [ChannelQueries] table. -@DriftAccessor(tables: [ChannelQueries, Channels, Users]) +@DriftAccessor( + tables: [ChannelQueries, ChannelQueriesMetadata, Channels, Users]) class ChannelQueryDao extends DatabaseAccessor with _$ChannelQueryDaoMixin { /// Creates a new channel query dao instance @@ -25,6 +27,19 @@ class ChannelQueryDao extends DatabaseAccessor return hash; } + String _computeHashForPredefined( + String filterName, + Map? filterValues, + Map? sortValues, + ) { + final payload = { + 'name': filterName, + if (filterValues != null) 'filter_values': filterValues, + if (sortValues != null) 'sort_values': sortValues, + }; + return 'p:${base64Encode(utf8.encode(jsonEncode(payload)))}'; + } + /// Update list of channel queries /// If [clearQueryCache] is true before the insert /// the list of matching rows will be deleted @@ -55,7 +70,61 @@ class ChannelQueryDao extends DatabaseAccessor }); }); + /// Update list of channel queries for a predefined-filter query. /// + /// Writes the cached cids to [ChannelQueries] and the server-resolved + /// [filter] and [sort] to [ChannelQueriesMetadata] in a single transaction. + /// If [clearQueryCache] is true, prior cids and metadata for this query are + /// deleted before the insert. + Future updateChannelQueriesByPredefinedFilter( + String filterName, + List cids, { + required Filter filter, + required SortOrder sort, + Map? filterValues, + Map? sortValues, + bool clearQueryCache = false, + }) async => + transaction(() async { + final hash = + _computeHashForPredefined(filterName, filterValues, sortValues); + + if (clearQueryCache) { + await batch((it) { + it + ..deleteWhere( + channelQueries, + (c) => c.queryHash.equals(hash), + ) + ..deleteWhere( + channelQueriesMetadata, + (m) => m.queryHash.equals(hash), + ); + }); + } + + await batch((it) { + it + ..insertAllOnConflictUpdate( + channelQueries, + cids + .map((cid) => + ChannelQueryEntity(queryHash: hash, channelCid: cid)) + .toList(), + ) + ..insert( + channelQueriesMetadata, + ChannelQueryMetadataEntity( + queryHash: hash, + filter: filter, + sort: sort, + ), + mode: InsertMode.insertOrReplace, + ); + }); + }); + + /// Get the CIDs stored under the hash generated by [filter]. Future> getCachedChannelCids(Filter? filter) { final hash = _computeHash(filter); return (select(channelQueries)..where((c) => c.queryHash.equals(hash))) @@ -78,4 +147,47 @@ class ChannelQueryDao extends DatabaseAccessor return cachedChannels; } + + /// Get the cached channels and persisted filter/sort spec for a + /// predefined-filter query. + /// + /// Returns a record `(channels, filter, sort)`. The two + /// spec fields are null when no metadata row exists for this query. + Future<(List, Filter?, SortOrder?)> + getChannelsAndSpecByPredefinedFilter( + String filterName, { + Map? filterValues, + Map? sortValues, + }) async { + final hash = + _computeHashForPredefined(filterName, filterValues, sortValues); + + final cidsQuery = (select(channelQueries) + ..where((c) => c.queryHash.equals(hash))) + .map((c) => c.channelCid) + .get(); + + final metadataQuery = (select(channelQueriesMetadata) + ..where((m) => m.queryHash.equals(hash))) + .getSingleOrNull(); + + final (cachedCids, metadata) = await (cidsQuery, metadataQuery).wait; + + if (cachedCids.isEmpty) { + return (const [], metadata?.filter, metadata?.sort); + } + + final cachedChannels = + await (select(channels)..where((c) => c.cid.isIn(cachedCids))).join([ + leftOuterJoin(users, channels.createdById.equalsExp(users.id)), + ]).map((row) { + final createdByEntity = row.readTableOrNull(users); + final channelEntity = row.readTable(channels); + return channelEntity.toChannelModel( + createdBy: createdByEntity?.toUser(), + ); + }).get(); + + return (cachedChannels, metadata?.filter, metadata?.sort); + } } diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart index 07a9dabbb6..0508efc559 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart @@ -5,6 +5,8 @@ part of 'channel_query_dao.dart'; // ignore_for_file: type=lint mixin _$ChannelQueryDaoMixin on DatabaseAccessor { $ChannelQueriesTable get channelQueries => attachedDatabase.channelQueries; + $ChannelQueriesMetadataTable get channelQueriesMetadata => + attachedDatabase.channelQueriesMetadata; $ChannelsTable get channels => attachedDatabase.channels; $UsersTable get users => attachedDatabase.users; } 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 bc4814a95f..ed97eb6e73 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 @@ -23,6 +23,7 @@ part 'drift_chat_database.g.dart'; Members, Reads, ChannelQueries, + ChannelQueriesMetadata, ConnectionEvents, ], daos: [ @@ -55,7 +56,7 @@ class DriftChatDatabase extends _$DriftChatDatabase { // you should bump this number whenever you change or add a table definition. @override - int get schemaVersion => 29; + int get schemaVersion => 30; // Store DateTime as ISO-8601 text to preserve sub-second precision. @override 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 ed66a1dc08..f2f105563d 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 @@ -8229,6 +8229,243 @@ class ChannelQueriesCompanion extends UpdateCompanion { } } +class $ChannelQueriesMetadataTable extends ChannelQueriesMetadata + with TableInfo<$ChannelQueriesMetadataTable, ChannelQueryMetadataEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ChannelQueriesMetadataTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _queryHashMeta = + const VerificationMeta('queryHash'); + @override + late final GeneratedColumn queryHash = GeneratedColumn( + 'query_hash', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + late final GeneratedColumnWithTypeConverter filter = + GeneratedColumn('filter', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter($ChannelQueriesMetadataTable.$converterfilter); + @override + late final GeneratedColumnWithTypeConverter, String> + sort = GeneratedColumn('sort', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter>( + $ChannelQueriesMetadataTable.$convertersort); + @override + List get $columns => [queryHash, filter, sort]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'channel_queries_metadata'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('query_hash')) { + context.handle(_queryHashMeta, + queryHash.isAcceptableOrUnknown(data['query_hash']!, _queryHashMeta)); + } else if (isInserting) { + context.missing(_queryHashMeta); + } + return context; + } + + @override + Set get $primaryKey => {queryHash}; + @override + ChannelQueryMetadataEntity map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ChannelQueryMetadataEntity( + queryHash: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}query_hash'])!, + filter: $ChannelQueriesMetadataTable.$converterfilter.fromSql( + attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}filter'])!), + sort: $ChannelQueriesMetadataTable.$convertersort.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}sort'])!), + ); + } + + @override + $ChannelQueriesMetadataTable createAlias(String alias) { + return $ChannelQueriesMetadataTable(attachedDatabase, alias); + } + + static TypeConverter $converterfilter = + const FilterConverter(); + static TypeConverter, String> $convertersort = + const ChannelStateSortOrderConverter(); +} + +class ChannelQueryMetadataEntity extends DataClass + implements Insertable { + /// The query hash this metadata is associated with. Matches the hashes + /// produced by `ChannelQueryDao` for predefined-filter queries. + final String queryHash; + + /// The server-resolved filter spec to surface on offline reads. + final Filter filter; + + /// The server-resolved sort spec to apply on offline reads. + final SortOrder sort; + const ChannelQueryMetadataEntity( + {required this.queryHash, required this.filter, required this.sort}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['query_hash'] = Variable(queryHash); + { + map['filter'] = Variable( + $ChannelQueriesMetadataTable.$converterfilter.toSql(filter)); + } + { + map['sort'] = Variable( + $ChannelQueriesMetadataTable.$convertersort.toSql(sort)); + } + return map; + } + + factory ChannelQueryMetadataEntity.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ChannelQueryMetadataEntity( + queryHash: serializer.fromJson(json['queryHash']), + filter: serializer.fromJson(json['filter']), + sort: serializer.fromJson>(json['sort']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'queryHash': serializer.toJson(queryHash), + 'filter': serializer.toJson(filter), + 'sort': serializer.toJson>(sort), + }; + } + + ChannelQueryMetadataEntity copyWith( + {String? queryHash, Filter? filter, SortOrder? sort}) => + ChannelQueryMetadataEntity( + queryHash: queryHash ?? this.queryHash, + filter: filter ?? this.filter, + sort: sort ?? this.sort, + ); + ChannelQueryMetadataEntity copyWithCompanion( + ChannelQueriesMetadataCompanion data) { + return ChannelQueryMetadataEntity( + queryHash: data.queryHash.present ? data.queryHash.value : this.queryHash, + filter: data.filter.present ? data.filter.value : this.filter, + sort: data.sort.present ? data.sort.value : this.sort, + ); + } + + @override + String toString() { + return (StringBuffer('ChannelQueryMetadataEntity(') + ..write('queryHash: $queryHash, ') + ..write('filter: $filter, ') + ..write('sort: $sort') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(queryHash, filter, sort); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ChannelQueryMetadataEntity && + other.queryHash == this.queryHash && + other.filter == this.filter && + other.sort == this.sort); +} + +class ChannelQueriesMetadataCompanion + extends UpdateCompanion { + final Value queryHash; + final Value filter; + final Value> sort; + final Value rowid; + const ChannelQueriesMetadataCompanion({ + this.queryHash = const Value.absent(), + this.filter = const Value.absent(), + this.sort = const Value.absent(), + this.rowid = const Value.absent(), + }); + ChannelQueriesMetadataCompanion.insert({ + required String queryHash, + required Filter filter, + required SortOrder sort, + this.rowid = const Value.absent(), + }) : queryHash = Value(queryHash), + filter = Value(filter), + sort = Value(sort); + static Insertable custom({ + Expression? queryHash, + Expression? filter, + Expression? sort, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (queryHash != null) 'query_hash': queryHash, + if (filter != null) 'filter': filter, + if (sort != null) 'sort': sort, + if (rowid != null) 'rowid': rowid, + }); + } + + ChannelQueriesMetadataCompanion copyWith( + {Value? queryHash, + Value? filter, + Value>? sort, + Value? rowid}) { + return ChannelQueriesMetadataCompanion( + queryHash: queryHash ?? this.queryHash, + filter: filter ?? this.filter, + sort: sort ?? this.sort, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (queryHash.present) { + map['query_hash'] = Variable(queryHash.value); + } + if (filter.present) { + map['filter'] = Variable( + $ChannelQueriesMetadataTable.$converterfilter.toSql(filter.value)); + } + if (sort.present) { + map['sort'] = Variable( + $ChannelQueriesMetadataTable.$convertersort.toSql(sort.value)); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ChannelQueriesMetadataCompanion(') + ..write('queryHash: $queryHash, ') + ..write('filter: $filter, ') + ..write('sort: $sort, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $ConnectionEventsTable extends ConnectionEvents with TableInfo<$ConnectionEventsTable, ConnectionEventEntity> { @override @@ -8640,6 +8877,8 @@ abstract class _$DriftChatDatabase extends GeneratedDatabase { late final $MembersTable members = $MembersTable(this); late final $ReadsTable reads = $ReadsTable(this); late final $ChannelQueriesTable channelQueries = $ChannelQueriesTable(this); + late final $ChannelQueriesMetadataTable channelQueriesMetadata = + $ChannelQueriesMetadataTable(this); late final $ConnectionEventsTable connectionEvents = $ConnectionEventsTable(this); late final UserDao userDao = UserDao(this as DriftChatDatabase); @@ -8677,6 +8916,7 @@ abstract class _$DriftChatDatabase extends GeneratedDatabase { members, reads, channelQueries, + channelQueriesMetadata, connectionEvents ]; @override @@ -13926,6 +14166,161 @@ typedef $$ChannelQueriesTableProcessedTableManager = ProcessedTableManager< ), ChannelQueryEntity, PrefetchHooks Function()>; +typedef $$ChannelQueriesMetadataTableCreateCompanionBuilder + = ChannelQueriesMetadataCompanion Function({ + required String queryHash, + required Filter filter, + required SortOrder sort, + Value rowid, +}); +typedef $$ChannelQueriesMetadataTableUpdateCompanionBuilder + = ChannelQueriesMetadataCompanion Function({ + Value queryHash, + Value filter, + Value> sort, + Value rowid, +}); + +class $$ChannelQueriesMetadataTableFilterComposer + extends Composer<_$DriftChatDatabase, $ChannelQueriesMetadataTable> { + $$ChannelQueriesMetadataTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get queryHash => $composableBuilder( + column: $table.queryHash, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters get filter => + $composableBuilder( + column: $table.filter, + builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnWithTypeConverterFilters, + SortOrder, String> + get sort => $composableBuilder( + column: $table.sort, + builder: (column) => ColumnWithTypeConverterFilters(column)); +} + +class $$ChannelQueriesMetadataTableOrderingComposer + extends Composer<_$DriftChatDatabase, $ChannelQueriesMetadataTable> { + $$ChannelQueriesMetadataTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get queryHash => $composableBuilder( + column: $table.queryHash, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get filter => $composableBuilder( + column: $table.filter, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get sort => $composableBuilder( + column: $table.sort, builder: (column) => ColumnOrderings(column)); +} + +class $$ChannelQueriesMetadataTableAnnotationComposer + extends Composer<_$DriftChatDatabase, $ChannelQueriesMetadataTable> { + $$ChannelQueriesMetadataTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get queryHash => + $composableBuilder(column: $table.queryHash, builder: (column) => column); + + GeneratedColumnWithTypeConverter get filter => + $composableBuilder(column: $table.filter, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get sort => + $composableBuilder(column: $table.sort, builder: (column) => column); +} + +class $$ChannelQueriesMetadataTableTableManager extends RootTableManager< + _$DriftChatDatabase, + $ChannelQueriesMetadataTable, + ChannelQueryMetadataEntity, + $$ChannelQueriesMetadataTableFilterComposer, + $$ChannelQueriesMetadataTableOrderingComposer, + $$ChannelQueriesMetadataTableAnnotationComposer, + $$ChannelQueriesMetadataTableCreateCompanionBuilder, + $$ChannelQueriesMetadataTableUpdateCompanionBuilder, + ( + ChannelQueryMetadataEntity, + BaseReferences<_$DriftChatDatabase, $ChannelQueriesMetadataTable, + ChannelQueryMetadataEntity> + ), + ChannelQueryMetadataEntity, + PrefetchHooks Function()> { + $$ChannelQueriesMetadataTableTableManager( + _$DriftChatDatabase db, $ChannelQueriesMetadataTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ChannelQueriesMetadataTableFilterComposer( + $db: db, $table: table), + createOrderingComposer: () => + $$ChannelQueriesMetadataTableOrderingComposer( + $db: db, $table: table), + createComputedFieldComposer: () => + $$ChannelQueriesMetadataTableAnnotationComposer( + $db: db, $table: table), + updateCompanionCallback: ({ + Value queryHash = const Value.absent(), + Value filter = const Value.absent(), + Value> sort = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ChannelQueriesMetadataCompanion( + queryHash: queryHash, + filter: filter, + sort: sort, + rowid: rowid, + ), + createCompanionCallback: ({ + required String queryHash, + required Filter filter, + required SortOrder sort, + Value rowid = const Value.absent(), + }) => + ChannelQueriesMetadataCompanion.insert( + queryHash: queryHash, + filter: filter, + sort: sort, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$ChannelQueriesMetadataTableProcessedTableManager + = ProcessedTableManager< + _$DriftChatDatabase, + $ChannelQueriesMetadataTable, + ChannelQueryMetadataEntity, + $$ChannelQueriesMetadataTableFilterComposer, + $$ChannelQueriesMetadataTableOrderingComposer, + $$ChannelQueriesMetadataTableAnnotationComposer, + $$ChannelQueriesMetadataTableCreateCompanionBuilder, + $$ChannelQueriesMetadataTableUpdateCompanionBuilder, + ( + ChannelQueryMetadataEntity, + BaseReferences<_$DriftChatDatabase, $ChannelQueriesMetadataTable, + ChannelQueryMetadataEntity> + ), + ChannelQueryMetadataEntity, + PrefetchHooks Function()>; typedef $$ConnectionEventsTableCreateCompanionBuilder = ConnectionEventsCompanion Function({ Value id, @@ -14162,6 +14557,9 @@ class $DriftChatDatabaseManager { $$ReadsTableTableManager(_db, _db.reads); $$ChannelQueriesTableTableManager get channelQueries => $$ChannelQueriesTableTableManager(_db, _db.channelQueries); + $$ChannelQueriesMetadataTableTableManager get channelQueriesMetadata => + $$ChannelQueriesMetadataTableTableManager( + _db, _db.channelQueriesMetadata); $$ConnectionEventsTableTableManager get connectionEvents => $$ConnectionEventsTableTableManager(_db, _db.connectionEvents); } diff --git a/packages/stream_chat_persistence/lib/src/entity/channel_queries_metadata.dart b/packages/stream_chat_persistence/lib/src/entity/channel_queries_metadata.dart new file mode 100644 index 0000000000..99441f81e6 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/channel_queries_metadata.dart @@ -0,0 +1,24 @@ +// coverage:ignore-file +import 'package:drift/drift.dart'; +import 'package:stream_chat_persistence/src/converter/converter.dart'; + +/// Represents a [ChannelQueriesMetadata] table in `DriftChatDatabase`. +/// +/// Holds side-information about a channel query keyed by its [queryHash]. +/// Stores the server-resolved filter and sort spec used by predefined-filter +/// queries so that offline reads can reconstruct the full resolved spec. +@DataClassName('ChannelQueryMetadataEntity') +class ChannelQueriesMetadata extends Table { + /// The query hash this metadata is associated with. Matches the hashes + /// produced by `ChannelQueryDao` for predefined-filter queries. + TextColumn get queryHash => text()(); + + /// The server-resolved filter spec to surface on offline reads. + TextColumn get filter => text().map(const FilterConverter())(); + + /// The server-resolved sort spec to apply on offline reads. + TextColumn get sort => text().map(const ChannelStateSortOrderConverter())(); + + @override + Set get primaryKey => {queryHash}; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/entity.dart b/packages/stream_chat_persistence/lib/src/entity/entity.dart index 2ef87c5cb6..946b32e532 100644 --- a/packages/stream_chat_persistence/lib/src/entity/entity.dart +++ b/packages/stream_chat_persistence/lib/src/entity/entity.dart @@ -1,4 +1,5 @@ export 'channel_queries.dart'; +export 'channel_queries_metadata.dart'; export 'channels.dart'; export 'connection_events.dart'; export 'draft_messages.dart'; diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index da8032bb62..d803104771 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -261,6 +261,7 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { ); } + @Deprecated('Use queryChannelStates instead') @override Future> getChannelStates({ Filter? filter, @@ -273,43 +274,141 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { // 1) Lightweight load — channel rows + createdBy user only. final channelModels = await db!.channelQueryDao.getChannels(filter: filter); - // 2) Wrap each model in a sort envelope. No state loaded yet. + return _getChannelStatesPage( + channelModels, + channelStateSort, + paginationParams, + ); + } + + /// Drift-backed implementation of + /// [ChatPersistenceClient.queryChannelStates]. + @override + Future queryChannelStates({ + Filter? filter, + SortOrder? sort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + PaginationParams? paginationParams, + }) async { + assert(_debugIsConnected, ''); + _logger.info('queryChannelStates'); + if (predefinedFilter != null) { + final (channelModels, resolvedFilter, resolvedSort) = + await db!.channelQueryDao.getChannelsAndSpecByPredefinedFilter( + predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + ); + + final channels = await _getChannelStatesPage( + channelModels, + resolvedSort, + paginationParams, + ); + final spec = resolvedFilter == null + ? null + : PredefinedFilter( + name: predefinedFilter, + filter: resolvedFilter, + sort: resolvedSort, + ); + + return QueryChannelsResponse() + ..channels = channels + ..predefinedFilter = spec; + } + + final channelModels = await db!.channelQueryDao.getChannels(filter: filter); + final channels = await _getChannelStatesPage( + channelModels, + sort, + paginationParams, + ); + return QueryChannelsResponse()..channels = channels; + } + + // Wraps channel models in sort envelopes, attaches memberships when the + // sort needs them, sorts, slices the requested page, and hydrates only the + // page with full channel state. + Future> _getChannelStatesPage( + List channelModels, + SortOrder? channelStateSort, + PaginationParams? paginationParams, + ) async { + // 1) Wrap each model in a sort envelope. No state loaded yet. var envelopes = channelModels .map((m) => ChannelState(channel: m)) .toList(growable: false); - // 3) If sort uses `pinnedAt`, preload the current user's memberships in + // 2) If sort uses `pinnedAt`, preload the current user's memberships in // one batched query and attach them to the envelopes. final clientUserId = userId; if (clientUserId != null && _sortRequiresMembership(channelStateSort)) { envelopes = await _attachMemberships(envelopes, clientUserId); } - // 4) Sort using the existing comparator — same logic as today, just on - // envelopes instead of fully-hydrated states. + // 3) Sort using the comparator — on envelopes instead of fully-hydrated + // states. if (channelStateSort != null && channelStateSort.isNotEmpty) { envelopes.sort(channelStateSort.compare); } - // 5) Slice the page. + // 4) Slice the page. final total = envelopes.length; final offset = (paginationParams?.offset ?? 0).clamp(0, total); final limit = paginationParams?.limit ?? (total - offset); final pagedCids = envelopes.skip(offset).take(limit).map((s) => s.channel!.cid).toList(); - // 6) Hydrate ONLY the page. + // 5) Hydrate ONLY the page. return Future.wait(pagedCids.map(getChannelStateByCid)); } + @Deprecated('Use saveChannelQueries instead') @override Future updateChannelQueries( Filter? filter, List cids, { bool clearQueryCache = false, + }) { + return saveChannelQueries( + cids: cids, + filter: filter, + clearQueryCache: clearQueryCache, + ); + } + + /// Drift-backed implementation of + /// [ChatPersistenceClient.saveChannelQueries]. + @override + Future saveChannelQueries({ + required List cids, + Filter? filter, + SortOrder? sort, + String? predefinedFilter, + Filter? resolvedFilter, + SortOrder? resolvedSort, + Map? filterValues, + Map? sortValues, + bool clearQueryCache = false, }) { assert(_debugIsConnected, ''); - _logger.info('updateChannelQueries'); + _logger.info('saveChannelQueries'); + if (predefinedFilter != null) { + return db!.channelQueryDao.updateChannelQueriesByPredefinedFilter( + predefinedFilter, + cids, + filter: resolvedFilter ?? const Filter.empty(), + sort: resolvedSort ?? const [], + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: clearQueryCache, + ); + } + // Standard path's DAO hash currently keys on `filter` only; `sort` is + // accepted at the public API but not consumed here. return db!.channelQueryDao.updateChannelQueries( filter, cids, diff --git a/packages/stream_chat_persistence/test/src/converter/filter_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/filter_converter_test.dart new file mode 100644 index 0000000000..bee55dccb0 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/converter/filter_converter_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/converter/filter_converter.dart'; + +void main() { + const converter = FilterConverter(); + + test('empty filter round-trips as empty', () { + const original = Filter.empty(); + + final decoded = converter.fromSql(converter.toSql(original)); + + expect(decoded.toJson(), isEmpty); + }); + + test('raw map filter round-trips unchanged', () { + const original = Filter.raw( + value: { + 'type': 'messaging', + 'members': ['user-1', 'user-2'], + }, + ); + + final decoded = converter.fromSql(converter.toSql(original)); + + expect(decoded.toJson(), original.toJson()); + }); +} diff --git a/packages/stream_chat_persistence/test/src/converter/sort_order_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/sort_order_converter_test.dart new file mode 100644 index 0000000000..eddfe2d8db --- /dev/null +++ b/packages/stream_chat_persistence/test/src/converter/sort_order_converter_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/converter/sort_order_converter.dart'; + +void main() { + const converter = ChannelStateSortOrderConverter(); + + test('non-empty SortOrder round-trips unchanged', () { + const original = >[ + SortOption.desc(ChannelSortKey.pinnedAt), + SortOption.asc(ChannelSortKey.createdAt), + ]; + + final decoded = converter.fromSql(converter.toSql(original)); + + expect(decoded.length, original.length); + for (var i = 0; i < original.length; i++) { + expect(decoded[i].field, original[i].field); + expect(decoded[i].direction, original[i].direction); + } + }); + + test('empty SortOrder round-trips as empty', () { + final decoded = converter.fromSql(converter.toSql(const [])); + expect(decoded, isEmpty); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart index 5f8c477618..e382af597b 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart @@ -142,6 +142,112 @@ void main() { }); }); + Future insertChannelsForCids(List cids) async { + final users = + List.generate(cids.length, (i) => User(id: 'user_${cids[i]}')); + final channelModels = List.generate( + cids.length, + (i) => ChannelModel( + id: 'id_${cids[i]}', + type: 'messaging', + cid: cids[i], + createdBy: users[i], + config: ChannelConfig(), + ), + ); + await database.userDao.updateUsers(users); + await database.channelDao.updateChannels(channelModels); + } + + test('updateChannelQueriesByPredefinedFilter', () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'testUserId'}; + const sortValues = {'pinned_at': true}; + const cids = ['testCid1', 'testCid2', 'testCid3']; + final filter = Filter.equal('type', 'messaging'); + const sort = >[ + SortOption.desc(ChannelSortKey.pinnedAt), + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + await insertChannelsForCids(cids); + await channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + ); + + final (cachedChannels, storedFilter, storedSort) = + await channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(cachedChannels.map((c) => c.cid).toSet(), cids.toSet()); + expect(storedFilter, isNotNull); + expect(storedFilter!.toJson(), filter.toJson()); + expect(storedSort, isNotNull); + expect(storedSort!.length, 2); + expect(storedSort.first.field, ChannelSortKey.pinnedAt); + expect(storedSort.first.direction, SortOption.DESC); + expect(storedSort.last.field, ChannelSortKey.lastMessageAt); + expect(storedSort.last.direction, SortOption.DESC); + }); + + test('clear queryCache before updateChannelQueriesByPredefinedFilter', + () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'testUserId'}; + const sortValues = {'pinned_at': true}; + const oldCids = ['oldCid1', 'oldCid2']; + const newCids = ['newCid1']; + final filter = Filter.equal('type', 'messaging'); + const sort = >[ + SortOption.desc(ChannelSortKey.pinnedAt), + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + await insertChannelsForCids([...oldCids, ...newCids]); + await channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + oldCids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + ); + await channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + newCids, + filter: filter, + sort: sort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ); + + final (cachedChannels, storedFilter, storedSort) = + await channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: filterValues, + sortValues: sortValues, + ); + + expect(cachedChannels.map((c) => c.cid).toSet(), newCids.toSet()); + expect(storedFilter, isNotNull); + expect(storedFilter!.toJson(), filter.toJson()); + expect(storedSort, isNotNull); + expect(storedSort!.length, 2); + expect(storedSort.first.field, ChannelSortKey.pinnedAt); + expect(storedSort.first.direction, SortOption.DESC); + expect(storedSort.last.field, ChannelSortKey.lastMessageAt); + expect(storedSort.last.direction, SortOption.DESC); + }); + tearDown(() async { await database.disconnect(); }); diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index f4026937da..3763b090de 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -102,6 +104,8 @@ void main() { setUpAll(() { registerFallbackValue([]); + registerFallbackValue(const Filter.empty()); + registerFallbackValue(const >[]); }); setUp(() async { @@ -448,6 +452,243 @@ void main() { .called(1); }); + group('queryChannelStates', () { + test('standard mode returns empty response when no channels match', + () async { + final filter = Filter.in_('members', const ['unknown_user']); + + when(() => mockDatabase.channelQueryDao.getChannels(filter: filter)) + .thenAnswer((_) async => []); + + final result = await client.queryChannelStates(filter: filter); + + expect(result.channels, isEmpty); + expect(result.predefinedFilter, isNull); + verify(() => mockDatabase.channelQueryDao.getChannels(filter: filter)) + .called(1); + verifyNever(() => mockDatabase.channelDao.getChannelByCid(any())); + }); + + test('standard mode clamps offset above total to an empty page', + () async { + // Unique cid prefix so verifyNever doesn't collide with calls from + // earlier tests in the same group (mocktail records across tests). + final channels = List.generate( + 3, + (i) => ChannelModel(cid: 'messaging:clamp_$i'), + ); + + when(() => mockDatabase.channelQueryDao.getChannels()) + .thenAnswer((_) async => channels); + + final result = await client.queryChannelStates( + paginationParams: const PaginationParams(offset: 100), + ); + + expect(result.channels, isEmpty); + expect(result.predefinedFilter, isNull); + for (var i = 0; i < 3; i++) { + verifyNever( + () => mockDatabase.channelDao.getChannelByCid('messaging:clamp_$i'), + ); + } + }); + + test( + 'predefined mode with no persisted spec returns null ' + 'predefinedFilter', () async { + const filterName = 'sample-app-list'; + + when(() => + mockDatabase.channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + )).thenAnswer((_) async => (const [], null, null)); + + final result = + await client.queryChannelStates(predefinedFilter: filterName); + + expect(result.channels, isEmpty); + expect(result.predefinedFilter, isNull); + }); + + test('predefined mode clamps offset above total to an empty page', + () async { + // Unique cid prefix so verifyNever doesn't collide with calls from + // earlier tests in the same group (mocktail records across tests). + const filterName = 'sample-app-list'; + final channels = List.generate( + 3, + (i) => ChannelModel(cid: 'messaging:clamp_p$i'), + ); + + when(() => + mockDatabase.channelQueryDao.getChannelsAndSpecByPredefinedFilter( + filterName, + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + )).thenAnswer((_) async => (channels, null, null)); + + final result = await client.queryChannelStates( + predefinedFilter: filterName, + paginationParams: const PaginationParams(offset: 100), + ); + + expect(result.channels, isEmpty); + expect(result.predefinedFilter, isNull); + for (var i = 0; i < 3; i++) { + verifyNever( + () => + mockDatabase.channelDao.getChannelByCid('messaging:clamp_p$i'), + ); + } + }); + }); + + group('saveChannelQueries', () { + test('standard mode forwards to channelQueryDao.updateChannelQueries', + () async { + final filter = Filter.in_('members', const ['testUserId']); + const cids = []; + when(() => + mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) + .thenAnswer((_) => Future.value()); + + await client.saveChannelQueries(cids: cids, filter: filter); + + verify(() => + mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) + .called(1); + }); + + test('standard mode ignores resolvedFilter and resolvedSort', () async { + final filter = Filter.in_('members', const ['testUserId']); + const cids = ['messaging:c0']; + final resolvedFilter = Filter.equal('type', 'messaging'); + const resolvedSort = >[ + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + when(() => mockDatabase.channelQueryDao.updateChannelQueries( + filter, + cids, + // ignore: avoid_redundant_argument_values + clearQueryCache: false, + )).thenAnswer((_) => Future.value()); + + await client.saveChannelQueries( + cids: cids, + filter: filter, + sort: resolvedSort, + resolvedFilter: resolvedFilter, + resolvedSort: resolvedSort, + ); + + verify(() => mockDatabase.channelQueryDao.updateChannelQueries( + filter, + cids, + // ignore: avoid_redundant_argument_values + clearQueryCache: false, + )).called(1); + verifyNever(() => + mockDatabase.channelQueryDao.updateChannelQueriesByPredefinedFilter( + any(), + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + filterValues: any(named: 'filterValues'), + sortValues: any(named: 'sortValues'), + clearQueryCache: any(named: 'clearQueryCache'), + )); + }); + + test( + 'predefined mode forwards all args to ' + 'channelQueryDao.updateChannelQueriesByPredefinedFilter', () async { + const filterName = 'sample-app-list'; + const filterValues = {'user_id': 'testUserId'}; + const sortValues = {'pinned_at': true}; + const cids = ['messaging:c0']; + final resolvedFilter = Filter.equal('type', 'messaging'); + const resolvedSort = >[ + SortOption.desc(ChannelSortKey.lastMessageAt), + ]; + + when(() => + mockDatabase.channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: resolvedFilter, + sort: resolvedSort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + )).thenAnswer((_) => Future.value()); + + await client.saveChannelQueries( + cids: cids, + predefinedFilter: filterName, + resolvedFilter: resolvedFilter, + resolvedSort: resolvedSort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + ); + + verify(() => + mockDatabase.channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: resolvedFilter, + sort: resolvedSort, + filterValues: filterValues, + sortValues: sortValues, + clearQueryCache: true, + )).called(1); + }); + + test( + 'predefined mode applies Filter.empty() and empty-sort fallback ' + 'when resolved values are null', () async { + const filterName = 'sample-app-list'; + const cids = ['messaging:c0']; + + when(() => + mockDatabase.channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: const Filter.empty(), + sort: const [], + // ignore: avoid_redundant_argument_values + filterValues: null, + // ignore: avoid_redundant_argument_values + sortValues: null, + // ignore: avoid_redundant_argument_values + clearQueryCache: false, + )).thenAnswer((_) => Future.value()); + + await client.saveChannelQueries( + cids: cids, + predefinedFilter: filterName, + ); + + verify(() => + mockDatabase.channelQueryDao.updateChannelQueriesByPredefinedFilter( + filterName, + cids, + filter: const Filter.empty(), + sort: const [], + // ignore: avoid_redundant_argument_values + filterValues: null, + // ignore: avoid_redundant_argument_values + sortValues: null, + // ignore: avoid_redundant_argument_values + clearQueryCache: false, + )).called(1); + }); + }); + test('deleteMessageById', () async { const messageId = 'testMessageId'; when(() => mockDatabase.messageDao.deleteMessageByIds([messageId])) diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index c2dab2bb85..2961d92cc2 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -55,14 +55,8 @@ class _ChannelList extends State { late final _channelListController = StreamChannelListController( client: StreamChat.of(context).client, - filter: Filter.in_('members', [StreamChat.of(context).currentUser!.id]), - channelStateSort: [ - const SortOption.desc( - ChannelSortKey.pinnedAt, - nullOrdering: NullOrdering.nullsLast, - ), - const SortOption.desc(ChannelSortKey.lastMessageAt), - ], + predefinedFilter: 'stream_chat_flutter_sample_app_v9', + filterValues: {'user_id': StreamChat.of(context).currentUser!.id}, limit: 30, );