diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 8c90f418c..c75b51ff0 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,4 +1,4 @@ -## 9.26.0 +## Upcoming ✅ Added @@ -6,6 +6,12 @@ 🐞 Fixed +- Fixed `StreamChannelListController` not handling `notification.channel_deleted` event. + +## 9.26.0 + +🐞 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. ## 9.25.0 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 28462517b..3d385845c 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 @@ -311,7 +311,8 @@ class StreamChannelListController extends PagedValueNotifier { if (eventListener?.call(event) ?? false) return; final eventType = event.type; - if (eventType == EventType.channelDeleted) { + if (eventType == EventType.channelDeleted || + eventType == EventType.notificationChannelDeleted) { _eventHandler.onChannelDeleted(event, this); } else if (eventType == EventType.channelHidden) { _eventHandler.onChannelHidden(event, this); 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 index b6438670c..8cea4c160 100644 --- 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 @@ -1,5 +1,7 @@ // ignore_for_file: avoid_redundant_argument_values +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart' hide Success; @@ -23,6 +25,52 @@ void main() { reset(client); }); + MockChannel mockChannel(String cid, {bool watchable = false}) { + final channel = MockChannel(); + when(() => channel.cid).thenReturn(cid); + if (watchable) { + when(channel.watch).thenAnswer((_) => Future.value(const ChannelState())); + final [type, id] = cid.split(':'); + when(() => client.channel(type, id: id)).thenReturn(channel); + } + return channel; + } + + Future buildController({ + List channels = const [], + Filter? filter, + SortOrder? channelStateSort = defaultChannelListSort, + String? predefinedFilter, + Map? filterValues, + Map? sortValues, + }) async { + 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: channels))); + + final controller = StreamChannelListController( + client: client, + filter: filter, + channelStateSort: channelStateSort, + predefinedFilter: predefinedFilter, + filterValues: filterValues, + sortValues: sortValues, + ); + await controller.doInitialLoad(); + await pumpEventQueue(); + return controller; + } + test('creates with loading state by default', () { final controller = StreamChannelListController(client: client); expect(controller.value, isA()); @@ -45,30 +93,7 @@ void main() { 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(); + await buildController(filter: filter, channelStateSort: sort); verify( () => client.queryChannelsWithResult( @@ -94,33 +119,13 @@ void main() { 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, + await buildController( predefinedFilter: presetName, filterValues: filterValues, sortValues: sortValues, channelStateSort: null, ); - await controller.doInitialLoad(); - await pumpEventQueue(); - verify( () => client.queryChannelsWithResult( filter: null, @@ -280,6 +285,267 @@ void main() { }, ); + group('Event handling', () { + late StreamController eventController; + + setUp(() { + eventController = StreamController.broadcast(); + when(client.on).thenAnswer((_) => eventController.stream); + }); + + tearDown(() { + eventController.close(); + }); + + test('channel.deleted event removes the channel from the list', () async { + final keptChannel = mockChannel('messaging:kept'); + final deletedChannel = mockChannel('messaging:deleted'); + + final controller = await buildController( + channels: [keptChannel, deletedChannel], + channelStateSort: null, + ); + + eventController.add( + Event(type: EventType.channelDeleted, cid: 'messaging:deleted'), + ); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, equals([keptChannel])); + }); + + test( + 'notification.channel_deleted event removes the channel from the list', + () async { + final keptChannel = mockChannel('messaging:kept'); + final deletedChannel = mockChannel('messaging:deleted'); + + final controller = await buildController( + channels: [keptChannel, deletedChannel], + channelStateSort: null, + ); + + eventController.add( + Event( + type: EventType.notificationChannelDeleted, + cid: 'messaging:deleted', + ), + ); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, equals([keptChannel])); + }, + ); + + test('channel.hidden event removes the channel from the list', () async { + final keptChannel = mockChannel('messaging:kept'); + final hiddenChannel = mockChannel('messaging:hidden'); + + final controller = await buildController( + channels: [keptChannel, hiddenChannel], + channelStateSort: null, + ); + + eventController.add( + Event(type: EventType.channelHidden, cid: 'messaging:hidden'), + ); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, equals([keptChannel])); + }); + + test('channel.truncated event refreshes the list', () async { + final channel = mockChannel('messaging:foo'); + + await buildController(channels: [channel], channelStateSort: null); + + eventController.add(Event(type: EventType.channelTruncated)); + await pumpEventQueue(); + + verify( + () => 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'), + ), + ).called(2); + }); + + test('connection.recovered event refreshes the list', () async { + await buildController(channelStateSort: null); + + eventController.add(Event(type: EventType.connectionRecovered)); + await pumpEventQueue(); + + verify( + () => 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'), + ), + ).called(2); + }); + + test('message.new event moves the matching channel to the top', () async { + final channelA = mockChannel('messaging:a'); + final channelB = mockChannel('messaging:b'); + final channelC = mockChannel('messaging:c'); + + final controller = await buildController( + channels: [channelA, channelB, channelC], + channelStateSort: null, + ); + + eventController.add( + Event(type: EventType.messageNew, cid: 'messaging:b'), + ); + await pumpEventQueue(); + + expect( + controller.value.asSuccess.items, + equals([channelB, channelA, channelC]), + ); + }); + + test( + 'message.new event for a channel not in the list refreshes the list', + () async { + final channel = mockChannel('messaging:foo'); + + await buildController(channels: [channel], channelStateSort: null); + + eventController.add( + Event(type: EventType.messageNew, cid: 'messaging:unknown'), + ); + await pumpEventQueue(); + + verify( + () => 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'), + ), + ).called(2); + }, + ); + + test('channel.visible event adds the channel to the top of the list', + () async { + final existingChannel = mockChannel('messaging:existing'); + final newChannel = mockChannel('messaging:new', watchable: true); + + final controller = await buildController( + channels: [existingChannel], + channelStateSort: null, + ); + + eventController.add( + Event( + type: EventType.channelVisible, + channelId: 'new', + channelType: 'messaging', + ), + ); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, + equals([newChannel, existingChannel])); + }); + + test( + 'notification.added_to_channel event adds the channel to the top of the ' + 'list', + () async { + final existingChannel = mockChannel('messaging:existing'); + final newChannel = mockChannel('messaging:new', watchable: true); + + final controller = await buildController( + channels: [existingChannel], + channelStateSort: null, + ); + + eventController.add( + Event( + type: EventType.notificationAddedToChannel, + channelId: 'new', + channelType: 'messaging', + ), + ); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, + equals([newChannel, existingChannel])); + }, + ); + + test( + 'notification.message_new event adds the channel to the top of the list', + () async { + final existingChannel = mockChannel('messaging:existing'); + final newChannel = mockChannel('messaging:new', watchable: true); + + final controller = await buildController( + channels: [existingChannel], + channelStateSort: null, + ); + + eventController.add( + Event( + type: EventType.notificationMessageNew, + channelId: 'new', + channelType: 'messaging', + ), + ); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, + equals([newChannel, existingChannel])); + }, + ); + + test( + 'notification.removed_from_channel event removes the channel from the ' + 'list', + () async { + final keptChannel = mockChannel('messaging:kept'); + final removedChannel = mockChannel('messaging:removed'); + + final controller = await buildController( + channels: [keptChannel, removedChannel], + channelStateSort: null, + ); + + eventController.add( + Event( + type: EventType.notificationRemovedFromChannel, + channel: ChannelModel(cid: 'messaging:removed'), + ), + ); + await pumpEventQueue(); + + expect(controller.value.asSuccess.items, equals([keptChannel])); + }, + ); + }); + test('channels setter replaces items while keeping success state', () { const initial = PagedValue(items: []); final controller = StreamChannelListController.fromValue(