diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 20fb68aec..4b3c3fde3 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -3,6 +3,7 @@ ✅ Added - Added an `upsert` flag to `ChannelClientState.updateMessage` (defaults to `true`). Pass `false` to update a message only if it's already loaded in the state, skipping unknown messages instead of adding them. +- Added `EventType.userPresenceChanged` (`user.presence.changed`) constant. 🔄 Changed diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index 794139550..f9e482db6 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -72,6 +72,9 @@ class EventType { /// Event sent when a user is updated static const String userUpdated = 'user.updated'; + /// Event sent when a user's presence changes + static const String userPresenceChanged = 'user.presence.changed'; + /// Event sent when a member is added to a channel static const String memberAdded = 'member.added'; diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index fca1be726..b803e34b4 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -4,6 +4,7 @@ - Fixed `StreamChannelListController` not handling `notification.channel_deleted` event. - Fixed backwards pagination not working if channel was never opened. +- Guarded `StreamChannelListController`'s `channel.updated`, `member.updated`, and `user.presence.changed`/`user.updated` event handlers to skip the full list re-sort when the event doesn't affect any listed channel. ## 10.1.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 c20387629..f74b72149 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 @@ -332,7 +332,7 @@ class StreamChannelListController extends PagedValueNotifier { _eventHandler.onNotificationMessageNew(event, this); } else if (eventType == EventType.notificationRemovedFromChannel) { _eventHandler.onNotificationRemovedFromChannel(event, this); - } else if (eventType == 'user.presence.changed' || eventType == EventType.userUpdated) { + } else if (eventType == EventType.userPresenceChanged || eventType == EventType.userUpdated) { _eventHandler.onUserPresenceChanged(event, this); } else if (eventType == EventType.memberUpdated) { _eventHandler.onMemberUpdated(event, this); diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart index 59a21f13c..78dcce297 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart @@ -48,9 +48,13 @@ mixin class StreamChannelListEventHandler { /// /// This event is fired when a channel is updated. /// - /// By default, this updates the channel received in the event. + /// By default, this re-sorts the list when the updated channel is present in + /// it, and does nothing otherwise. void onChannelUpdated(Event event, StreamChannelListController controller) { - controller.channels = [...controller.currentItems]; + final cid = event.cid ?? event.channel?.cid; + final channels = controller.currentItems; + if (!channels.any((it) => it.cid == cid)) return; + controller.channels = [...channels]; } /// Function which gets called for the event @@ -58,9 +62,13 @@ mixin class StreamChannelListEventHandler { /// /// This event is fired when a member is updated. /// - /// By default, this sorts the channels. + /// By default, this re-sorts the list when the member's channel is present in + /// it, and does nothing otherwise. void onMemberUpdated(Event event, StreamChannelListController controller) { - controller.channels = [...controller.currentItems]; + final cid = event.cid ?? event.channel?.cid; + final channels = controller.currentItems; + if (!channels.any((it) => it.cid == cid)) return; + controller.channels = [...channels]; } /// Function which gets called for the event @@ -180,11 +188,14 @@ mixin class StreamChannelListEventHandler { } /// Function which gets called for the event - /// 'user.presence.changed' and [EventType.userUpdated]. + /// [EventType.userPresenceChanged] and [EventType.userUpdated]. /// /// This event is fired when a user's presence changes or gets updated. /// - /// By default, this updates the channel member with the event user. + /// By default, this updates the event user in every listed channel where they + /// appear — either among the loaded members, or, when the event is about the + /// current user, as the channel membership — then re-sorts the list. + /// Does nothing when no listed channel references the user. void onUserPresenceChanged( Event event, StreamChannelListController controller, @@ -194,12 +205,14 @@ mixin class StreamChannelListEventHandler { final channels = [...controller.currentItems]; - final updatedChannels = channels.map((channel) { + var updated = false; + for (final channel in channels) { final existingMembership = channel.membership; final existingMembers = [...channel.state!.members]; - // Return if the user is not a existing member of the channel. - if (!existingMembers.any((m) => m.userId == user.id)) return channel; + // Leave channels untouched where the user is not an existing member. + final containsUser = existingMembership?.userId == user.id || existingMembers.any((m) => m.userId == user.id); + if (!containsUser) continue; Member? maybeUpdateMemberUser(Member? existingMember) { if (existingMember == null) return null; @@ -216,9 +229,12 @@ mixin class StreamChannelListEventHandler { ), ); - return channel; - }); + updated = true; + } + + // Skip the re-sort when no listed channel contained the user. + if (!updated) return; - controller.channels = [...updatedChannels]; + controller.channels = [...channels]; } } diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart index cc705f983..0de611e5b 100644 --- a/packages/stream_chat_flutter_core/test/mocks.dart +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -1,8 +1,14 @@ import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_channel_list_controller.dart'; +import 'package:stream_chat_flutter_core/src/stream_channel_list_event_handler.dart'; class MockLogger extends Mock implements Logger {} +class MockStreamChannelListController extends Mock implements StreamChannelListController {} + +class MockStreamChannelListEventHandler extends Mock implements StreamChannelListEventHandler {} + class MockClient extends Mock implements StreamChatClient { MockClient() { when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); 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 d1971143e..491c1bd0a 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 @@ -7,12 +7,14 @@ 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 'package:stream_chat_flutter_core/src/stream_channel_list_event_handler.dart'; import 'mocks.dart'; void main() { setUpAll(() { registerFallbackValue(const PaginationParams()); + registerFallbackValue(Event(type: 'fallback')); }); final client = MockClient(); @@ -25,17 +27,6 @@ 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, @@ -43,6 +34,7 @@ void main() { String? predefinedFilter, Map? filterValues, Map? sortValues, + StreamChannelListEventHandler? eventHandler, }) async { when( () => client.queryChannelsWithResult( @@ -60,6 +52,7 @@ void main() { final controller = StreamChannelListController( client: client, + eventHandler: eventHandler, filter: filter, channelStateSort: channelStateSort, predefinedFilter: predefinedFilter, @@ -282,259 +275,129 @@ void main() { expect((captured.single as PaginationParams).offset, equals(nextPageKey)); }); - group('Event handling', () { + // The controller's only responsibility for events is routing them to the + // matching handler method. The handler's behavior for each event is covered + // in stream_channel_list_event_handler_test.dart, so here we inject a mock + // handler and assert only that dispatch reaches the right method. + group('Event handling routes each event to its handler', () { late StreamController eventController; + late MockStreamChannelListEventHandler handler; setUp(() { eventController = StreamController.broadcast(); when(client.on).thenAnswer((_) => eventController.stream); + handler = MockStreamChannelListEventHandler(); }); 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, - ); + Future subscribe() => buildController(eventHandler: handler); - eventController.add( - Event(type: EventType.channelDeleted, cid: 'messaging:deleted'), - ); + test('channel.deleted -> onChannelDeleted', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.channelDeleted)); await pumpEventQueue(); - - expect(controller.value.asSuccess.items, equals([keptChannel])); + verify(() => handler.onChannelDeleted(any(), controller)).called(1); }); - 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('notification.channel_deleted -> onChannelDeleted', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.notificationChannelDeleted)); + await pumpEventQueue(); + verify(() => handler.onChannelDeleted(any(), controller)).called(1); + }); - test('channel.hidden event removes the channel from the list', () async { - final keptChannel = mockChannel('messaging:kept'); - final hiddenChannel = mockChannel('messaging:hidden'); + test('channel.hidden -> onChannelHidden', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.channelHidden)); + await pumpEventQueue(); + verify(() => handler.onChannelHidden(any(), controller)).called(1); + }); - final controller = await buildController( - channels: [keptChannel, hiddenChannel], - channelStateSort: null, - ); + test('channel.truncated -> onChannelTruncated', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.channelTruncated)); + await pumpEventQueue(); + verify(() => handler.onChannelTruncated(any(), controller)).called(1); + }); - eventController.add( - Event(type: EventType.channelHidden, cid: 'messaging:hidden'), - ); + test('channel.updated -> onChannelUpdated', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.channelUpdated)); await pumpEventQueue(); + verify(() => handler.onChannelUpdated(any(), controller)).called(1); + }); - expect(controller.value.asSuccess.items, equals([keptChannel])); + test('member.updated -> onMemberUpdated', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.memberUpdated)); + await pumpEventQueue(); + verify(() => handler.onMemberUpdated(any(), controller)).called(1); }); - test('channel.truncated event refreshes the list', () async { - final channel = mockChannel('messaging:foo'); + test('channel.visible -> onChannelVisible', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.channelVisible)); + await pumpEventQueue(); + verify(() => handler.onChannelVisible(any(), controller)).called(1); + }); - await buildController(channels: [channel], channelStateSort: null); + test('connection.recovered -> onConnectionRecovered', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.connectionRecovered)); + await pumpEventQueue(); + verify(() => handler.onConnectionRecovered(any(), controller)).called(1); + }); - eventController.add(Event(type: EventType.channelTruncated)); + test('message.new -> onMessageNew', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.messageNew)); await pumpEventQueue(); + verify(() => handler.onMessageNew(any(), controller)).called(1); + }); + test('notification.added_to_channel -> onNotificationAddedToChannel', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.notificationAddedToChannel)); + 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); + () => handler.onNotificationAddedToChannel(any(), controller), + ).called(1); }); - test('connection.recovered event refreshes the list', () async { - await buildController(channelStateSort: null); - - eventController.add(Event(type: EventType.connectionRecovered)); + test('notification.message_new -> onNotificationMessageNew', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.notificationMessageNew)); 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); + () => handler.onNotificationMessageNew(any(), controller), + ).called(1); }); - 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'), - ); + test('notification.removed_from_channel -> onNotificationRemovedFromChannel', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.notificationRemovedFromChannel)); await pumpEventQueue(); - - expect( - controller.value.asSuccess.items, - equals([channelB, channelA, channelC]), - ); + verify( + () => handler.onNotificationRemovedFromChannel(any(), controller), + ).called(1); }); - 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', - ), - ); + test('user.presence.changed -> onUserPresenceChanged', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.userPresenceChanged)); await pumpEventQueue(); - - expect(controller.value.asSuccess.items, equals([newChannel, existingChannel])); + verify(() => handler.onUserPresenceChanged(any(), controller)).called(1); }); - 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('user.updated -> onUserPresenceChanged', () async { + final controller = await subscribe(); + eventController.add(Event(type: EventType.userUpdated)); + await pumpEventQueue(); + verify(() => handler.onUserPresenceChanged(any(), controller)).called(1); + }); }); test('channels setter replaces items while keeping success state', () { diff --git a/packages/stream_chat_flutter_core/test/stream_channel_list_event_handler_test.dart b/packages/stream_chat_flutter_core/test/stream_channel_list_event_handler_test.dart new file mode 100644 index 000000000..75d5c18f1 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_channel_list_event_handler_test.dart @@ -0,0 +1,448 @@ +// 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/stream_channel_list_event_handler.dart'; + +import 'mocks.dart'; + +void main() { + setUpAll(() { + registerFallbackValue([]); + registerFallbackValue(const ChannelState()); + }); + + late StreamChannelListEventHandler handler; + late MockStreamChannelListController controller; + + setUp(() { + handler = StreamChannelListEventHandler(); + controller = MockStreamChannelListController(); + }); + + MockChannel mockChannel( + String cid, { + List members = const [], + Member? membership, + }) { + final channel = MockChannel(); + final [type, id] = cid.split(':'); + + when(() => channel.cid).thenReturn(cid); + when(() => channel.membership).thenReturn(membership); + when(() => channel.state.members).thenReturn(members); + when(() => channel.state.channelState).thenReturn( + ChannelState( + channel: ChannelModel(cid: cid, id: id, type: type), + members: members, + ), + ); + + return channel; + } + + // Returns the single list passed to the controller's `channels` setter. + List capturedChannels() { + return verify(() => controller.channels = captureAny()).captured.single as List; + } + + group('onChannelDeleted', () { + test('removes the event channel from the list', () { + final kept = mockChannel('messaging:kept'); + final deleted = mockChannel('messaging:deleted'); + when(() => controller.currentItems).thenReturn([kept, deleted]); + + handler.onChannelDeleted( + Event(type: EventType.channelDeleted, cid: 'messaging:deleted'), + controller, + ); + + expect(capturedChannels(), equals([kept])); + }); + + test('falls back to the event channel cid', () { + final kept = mockChannel('messaging:kept'); + final deleted = mockChannel('messaging:deleted'); + when(() => controller.currentItems).thenReturn([kept, deleted]); + + handler.onChannelDeleted( + Event( + type: EventType.channelDeleted, + channel: ChannelModel(cid: 'messaging:deleted'), + ), + controller, + ); + + expect(capturedChannels(), equals([kept])); + }); + }); + + group('onChannelHidden', () { + test('removes the event channel from the list', () { + final kept = mockChannel('messaging:kept'); + final hidden = mockChannel('messaging:hidden'); + when(() => controller.currentItems).thenReturn([kept, hidden]); + + handler.onChannelHidden( + Event(type: EventType.channelHidden, cid: 'messaging:hidden'), + controller, + ); + + expect(capturedChannels(), equals([kept])); + }); + }); + + group('onChannelTruncated', () { + test('refreshes the whole list', () { + when(() => controller.refresh()).thenAnswer((_) async {}); + + handler.onChannelTruncated( + Event(type: EventType.channelTruncated), + controller, + ); + + verify(() => controller.refresh()).called(1); + }); + }); + + group('onChannelUpdated', () { + test('reassigns the list when the channel is listed', () { + final a = mockChannel('messaging:a'); + final b = mockChannel('messaging:b'); + when(() => controller.currentItems).thenReturn([a, b]); + + handler.onChannelUpdated( + Event(type: EventType.channelUpdated, cid: 'messaging:a'), + controller, + ); + + expect(capturedChannels(), equals([a, b])); + }); + + test('does nothing when the channel is not listed', () { + final a = mockChannel('messaging:a'); + when(() => controller.currentItems).thenReturn([a]); + + handler.onChannelUpdated( + Event(type: EventType.channelUpdated, cid: 'messaging:unknown'), + controller, + ); + + verifyNever(() => controller.channels = any()); + }); + }); + + group('onMemberUpdated', () { + test('reassigns the list when the channel is listed', () { + final a = mockChannel('messaging:a'); + final b = mockChannel('messaging:b'); + when(() => controller.currentItems).thenReturn([a, b]); + + handler.onMemberUpdated( + Event(type: EventType.memberUpdated, cid: 'messaging:a'), + controller, + ); + + expect(capturedChannels(), equals([a, b])); + }); + + test('does nothing when the channel is not listed', () { + final a = mockChannel('messaging:a'); + when(() => controller.currentItems).thenReturn([a]); + + handler.onMemberUpdated( + Event(type: EventType.memberUpdated, cid: 'messaging:unknown'), + controller, + ); + + verifyNever(() => controller.channels = any()); + }); + }); + + group('onChannelVisible', () { + test('adds the fetched channel to the top of the list', () async { + final existing = mockChannel('messaging:existing'); + final added = mockChannel('messaging:new'); + when(() => controller.currentItems).thenReturn([existing]); + when( + () => controller.getChannel(id: 'new', type: 'messaging'), + ).thenAnswer((_) async => added); + + handler.onChannelVisible( + Event( + type: EventType.channelVisible, + channelId: 'new', + channelType: 'messaging', + ), + controller, + ); + await pumpEventQueue(); + + expect(capturedChannels(), equals([added, existing])); + }); + + test('moves an already-listed channel to the top without duplicating', () async { + final existing = mockChannel('messaging:existing'); + final stale = mockChannel('messaging:new'); + final fetched = mockChannel('messaging:new'); + when(() => controller.currentItems).thenReturn([existing, stale]); + when( + () => controller.getChannel(id: 'new', type: 'messaging'), + ).thenAnswer((_) async => fetched); + + handler.onChannelVisible( + Event( + type: EventType.channelVisible, + channelId: 'new', + channelType: 'messaging', + ), + controller, + ); + await pumpEventQueue(); + + expect(capturedChannels(), equals([fetched, existing])); + }); + + test('is a no-op when the channel identifiers are missing', () async { + handler.onChannelVisible( + Event(type: EventType.channelVisible), + controller, + ); + await pumpEventQueue(); + + verifyNever( + () => controller.getChannel( + id: any(named: 'id'), + type: any(named: 'type'), + ), + ); + verifyNever(() => controller.channels = any()); + }); + }); + + group('onConnectionRecovered', () { + test('refreshes the whole list', () { + when(() => controller.refresh()).thenAnswer((_) async {}); + + handler.onConnectionRecovered( + Event(type: EventType.connectionRecovered), + controller, + ); + + verify(() => controller.refresh()).called(1); + }); + }); + + group('onMessageNew', () { + test('moves the matching channel to the top', () async { + final a = mockChannel('messaging:a'); + final b = mockChannel('messaging:b'); + final c = mockChannel('messaging:c'); + when(() => controller.currentItems).thenReturn([a, b, c]); + + handler.onMessageNew( + Event(type: EventType.messageNew, cid: 'messaging:b'), + controller, + ); + await pumpEventQueue(); + + expect(capturedChannels(), equals([b, a, c])); + }); + + test('refreshes without resetting when the channel is not listed', () async { + final a = mockChannel('messaging:a'); + when(() => controller.currentItems).thenReturn([a]); + when( + () => controller.refresh(resetValue: any(named: 'resetValue')), + ).thenAnswer((_) async {}); + + handler.onMessageNew( + Event(type: EventType.messageNew, cid: 'messaging:unknown'), + controller, + ); + await pumpEventQueue(); + + verify(() => controller.refresh(resetValue: false)).called(1); + verifyNever(() => controller.channels = any()); + }); + + test('is a no-op when the event has no cid', () async { + handler.onMessageNew(Event(type: EventType.messageNew), controller); + await pumpEventQueue(); + + verifyNever(() => controller.channels = any()); + verifyNever( + () => controller.refresh(resetValue: any(named: 'resetValue')), + ); + }); + }); + + group('onNotificationAddedToChannel', () { + test('adds the fetched channel to the top of the list', () async { + final existing = mockChannel('messaging:existing'); + final added = mockChannel('messaging:new'); + when(() => controller.currentItems).thenReturn([existing]); + when( + () => controller.getChannel(id: 'new', type: 'messaging'), + ).thenAnswer((_) async => added); + + handler.onNotificationAddedToChannel( + Event( + type: EventType.notificationAddedToChannel, + channelId: 'new', + channelType: 'messaging', + ), + controller, + ); + await pumpEventQueue(); + + expect(capturedChannels(), equals([added, existing])); + }); + }); + + group('onNotificationMessageNew', () { + test('adds the fetched channel to the top of the list', () async { + final existing = mockChannel('messaging:existing'); + final added = mockChannel('messaging:new'); + when(() => controller.currentItems).thenReturn([existing]); + when( + () => controller.getChannel(id: 'new', type: 'messaging'), + ).thenAnswer((_) async => added); + + handler.onNotificationMessageNew( + Event( + type: EventType.notificationMessageNew, + channelId: 'new', + channelType: 'messaging', + ), + controller, + ); + await pumpEventQueue(); + + expect(capturedChannels(), equals([added, existing])); + }); + }); + + group('onNotificationRemovedFromChannel', () { + test('removes the event channel when it is listed', () { + final kept = mockChannel('messaging:kept'); + final removed = mockChannel('messaging:removed'); + when(() => controller.currentItems).thenReturn([kept, removed]); + + handler.onNotificationRemovedFromChannel( + Event( + type: EventType.notificationRemovedFromChannel, + channel: ChannelModel(cid: 'messaging:removed'), + ), + controller, + ); + + expect(capturedChannels(), equals([kept])); + }); + + test('does nothing when the channel is not listed', () { + final kept = mockChannel('messaging:kept'); + when(() => controller.currentItems).thenReturn([kept]); + + handler.onNotificationRemovedFromChannel( + Event( + type: EventType.notificationRemovedFromChannel, + channel: ChannelModel(cid: 'messaging:unknown'), + ), + controller, + ); + + verifyNever(() => controller.channels = any()); + }); + }); + + group('onUserPresenceChanged', () { + test('updates matching channels and reassigns the list', () { + final user = User(id: 'u2', name: 'Updated'); + final memberChannel = mockChannel( + 'messaging:a', + members: [ + Member(userId: 'u1'), + Member(userId: 'u2'), + ], + ); + final otherChannel = mockChannel( + 'messaging:b', + members: [Member(userId: 'u1')], + ); + when( + () => controller.currentItems, + ).thenReturn([memberChannel, otherChannel]); + + handler.onUserPresenceChanged( + Event(type: EventType.userPresenceChanged, user: user), + controller, + ); + + verify(() => memberChannel.state.updateChannelState(any())).called(1); + verifyNever(() => otherChannel.state.updateChannelState(any())); + expect(capturedChannels(), equals([memberChannel, otherChannel])); + }); + + test('does nothing when the user is not a member of any channel', () { + final user = User(id: 'stranger'); + final a = mockChannel('messaging:a', members: [Member(userId: 'u1')]); + final b = mockChannel('messaging:b', members: [Member(userId: 'u2')]); + when(() => controller.currentItems).thenReturn([a, b]); + + handler.onUserPresenceChanged( + Event(type: EventType.userPresenceChanged, user: user), + controller, + ); + + verifyNever(() => a.state.updateChannelState(any())); + verifyNever(() => b.state.updateChannelState(any())); + verifyNever(() => controller.channels = any()); + }); + + test('updates the current user via membership when not a loaded member', () { + final currentUser = User(id: 'me', name: 'Renamed'); + final channel = mockChannel( + 'messaging:a', + members: [Member(userId: 'u1')], + membership: Member(userId: 'me'), + ); + when(() => controller.currentItems).thenReturn([channel]); + + handler.onUserPresenceChanged( + Event(type: EventType.userUpdated, user: currentUser), + controller, + ); + + verify(() => channel.state.updateChannelState(any())).called(1); + expect(capturedChannels(), equals([channel])); + }); + + test('routes user.updated through the same handler', () { + final user = User(id: 'u1', name: 'Renamed'); + final channel = mockChannel('messaging:a', members: [Member(userId: 'u1')]); + when(() => controller.currentItems).thenReturn([channel]); + + handler.onUserPresenceChanged( + Event(type: EventType.userUpdated, user: user), + controller, + ); + + verify(() => channel.state.updateChannelState(any())).called(1); + }); + + test('is a no-op when the event has no user', () { + final channel = mockChannel('messaging:a', members: [Member(userId: 'u1')]); + when(() => controller.currentItems).thenReturn([channel]); + + handler.onUserPresenceChanged( + Event(type: EventType.userPresenceChanged), + controller, + ); + + verifyNever(() => channel.state.updateChannelState(any())); + verifyNever(() => controller.channels = any()); + }); + }); +}