diff --git a/e2e_app/integration_test/channel_actions_test.dart b/e2e_app/integration_test/channel_actions_test.dart index 4c3062f838..a58edda0b9 100644 --- a/e2e_app/integration_test/channel_actions_test.dart +++ b/e2e_app/integration_test/channel_actions_test.dart @@ -125,7 +125,6 @@ void main() { streamTestWithEnv( allureId: '12684', description: 'app does not crash when the user goes back after being removed from the open channel', - skip: 'https://linear.app/stream/issue/FLU-1085', body: (env) async { step('GIVEN the user opens a channel'); await env.userRobot.login().openChannel(); diff --git a/e2e_app/integration_test/moderation_test.dart b/e2e_app/integration_test/moderation_test.dart index 786f1d2cd5..7c95305d31 100644 --- a/e2e_app/integration_test/moderation_test.dart +++ b/e2e_app/integration_test/moderation_test.dart @@ -170,7 +170,6 @@ void main() { streamTestWithEnv( allureId: '12791', description: 'user blocks the participant in a direct message channel', - skip: 'https://linear.app/stream/issue/FLU-1085', body: (env) async { step('GIVEN a direct message channel with the participant exists'); await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 2, withDirectMessageChannel: true); @@ -189,7 +188,6 @@ void main() { streamTestWithEnv( allureId: '12792', description: 'direct message channel disappears when the user blocks the participant', - skip: 'https://linear.app/stream/issue/FLU-1085', body: (env) async { step('GIVEN a direct message channel with the participant exists'); await env.backendRobot.generateChannels(channelsCount: 1, messagesCount: 2, withDirectMessageChannel: true); diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 7028ead278..d5e6765a38 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -2,6 +2,13 @@ 🔄 Changed +- `ChannelState.copyWith(membership: null)` now clears the membership instead of keeping it, as it already did for `draft`. + +🐞 Fixed + +- Fixed a `Channel` becoming unusable after the current user hides it or is removed from it, for example by blocking the other member of a direct message. +- Fixed `Channel.hidden` not following the `channel.hidden` and `channel.visible` events. +- Fixed the current user's membership staying on a channel after they are removed from it. - `Attachment.type` is now `url_preview` for every attachment scraped from a link in the message text, including a link to an image or file. Use `Attachment.rawType` to read the type it was created or received with. - `Attachment.toJson` keeps the original `type` of an attachment scraped from a link, such as `video` or `image`, instead of writing `url_preview`. diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index cd29cb61c3..4b3d47399c 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -2250,6 +2250,13 @@ class Channel { if (isQueryingAround) this.state?.truncate(); this.state?.updateChannelStateFromServer(channelState); + + // Watching a channel the user hid or was removed from makes it one of + // the client's channels again. + if (cid case final cid? when watch) { + final isListed = _client.state.channels.containsKey(cid); + if (!isListed) _client.state.addChannels({cid: this}); + } } // Submit for delivery reporting only when fetching the latest messages. diff --git a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart index 524ba55c30..58a09dba99 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_client_state.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_client_state.dart @@ -42,6 +42,7 @@ class ChannelClientState { removeTypingEvent: _removeTypingEvent, removeWatcher: _removeWatcher, removeMember: _removeMember, + clearMembership: _clearMembership, updateMember: _updateMember, deleteMessagesFromUser: _deleteMessagesFromUser, ); @@ -92,6 +93,11 @@ class ChannelClientState { ); } + void _clearMembership() { + if (channelState.membership == null) return; + _channelState = channelState.copyWith(membership: null); + } + // Replaces the member matching the given member's user id in the channel // state. Does nothing if no member with the same user id exists. void _updateMember(Member member) { @@ -581,7 +587,7 @@ class ChannelClientState { watchers: newWatchers.toList(), watcherCount: updatedState.watcherCount, members: updatedState.members, - membership: updatedState.membership, + membership: updatedState.membership ?? _channelState.membership, read: newReads.toList(), draft: updatedState.draft, pinnedMessages: newPinnedMessages, diff --git a/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart b/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart index 2cf32b3e4b..86d457cc88 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_event_handler.dart @@ -110,6 +110,10 @@ class ChannelEventHandler { _onChannelTruncated(event); case EventType.channelUpdated: _onChannelUpdated(event); + case EventType.channelHidden: + _mutations.onChannelHiddenChanged(hidden: true); + case EventType.channelVisible: + _mutations.onChannelHiddenChanged(hidden: false); } } @@ -121,6 +125,8 @@ class ChannelEventHandler { _onMemberAdded(event); case EventType.memberRemoved: _onMemberRemoved(event); + case EventType.notificationRemovedFromChannel: + _mutations.onCurrentUserRemoved(event.user!); } } @@ -397,6 +403,9 @@ class ChannelEventHandler { void _onMemberRemoved(Event event) { final user = event.user!; + if (user.id == _client.state.currentUser?.id) { + return _mutations.onCurrentUserRemoved(user); + } return _mutations.onMemberRemoved(user); } diff --git a/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart b/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart index 9a093cc62e..00021351ac 100644 --- a/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart +++ b/packages/stream_chat/lib/src/client/channel/channel_state_mutations.dart @@ -15,6 +15,7 @@ class ChannelStateMutations { required this._removeTypingEvent, required this._removeWatcher, required this._removeMember, + required this._clearMembership, required this._updateMember, required this._deleteMessagesFromUser, }); @@ -26,6 +27,7 @@ class ChannelStateMutations { final void Function(User user) _removeTypingEvent; final void Function(User watcher, {int? watcherCount}) _removeWatcher; final void Function(User user) _removeMember; + final void Function() _clearMembership; final void Function(Member member) _updateMember; final Future Function({ required String userId, @@ -430,6 +432,16 @@ class ChannelStateMutations { ); } + /// Records whether the current user has [hidden] the channel. + void onChannelHiddenChanged({required bool hidden}) { + final channel = _state.channelState.channel; + if (channel == null) return; + + _state.updateChannelState( + _state.channelState.copyWith(channel: channel.copyWith(hidden: hidden)), + ); + } + /// Updates the channel [memberCount] and [messageCount]. /// /// A null count leaves the currently stored value unchanged. @@ -458,6 +470,13 @@ class ChannelStateMutations { /// Removes the [user]'s membership and read state. void onMemberRemoved(User user) => _removeMember(user); + /// Removes the current user from the channel's members, read state and + /// membership after they leave or are removed from it. + void onCurrentUserRemoved(User user) { + _removeMember(user); + _clearMembership(); + } + /// Merges the updated [user] into the matching member and membership. /// /// Does nothing if the user is not an existing member of the channel. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 60d0e9927e..def45df02a 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -979,12 +979,14 @@ class StreamChatClient { MapEntry, List> _mapChannelStateToChannel( List channelStates, ) { - final channels = {...state.channels}; + final channels = {}; final newChannels = []; for (final channelState in channelStates) { - final channel = channels[channelState.channel!.cid]; + final cid = channelState.channel!.cid; + final channel = state._channelFor(cid); if (channel != null) { channel.state?.updateChannelStateFromServer(channelState); + channels[cid] = channel; newChannels.add(channel); } else { final newChannel = Channel.fromState(this, channelState); @@ -1298,8 +1300,8 @@ class StreamChatClient { String? id, Map? extraData, }) { - if (id != null && state.channels.containsKey('$type:$id')) { - return state.channels['$type:$id']!; + if (id != null) { + if (state._channelFor('$type:$id') case final channel?) return channel; } return Channel(this, type, id, extraData: extraData); } @@ -2576,6 +2578,7 @@ class ClientState { _listenChannelLeft(); _listenChannelDeleted(); _listenChannelHidden(); + _listenChannelVisible(); // endregion // region USER EVENTS @@ -2616,8 +2619,16 @@ class ClientState { _eventsSubscription?.add( _client.on(EventType.channelHidden).listen((event) async { final eventChannel = event.channel!; + _deactivateChannel(eventChannel.cid); await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - channels[eventChannel.cid]?.dispose(); + }), + ); + } + + void _listenChannelVisible() { + _eventsSubscription?.add( + _client.on(EventType.channelVisible).listen((event) { + if (event.channel?.cid case final cid?) _reactivateChannel(cid); }), ); } @@ -2654,7 +2665,7 @@ class ClientState { if (event.cid != null) return; // Update all channels' unread count to 0. - for (final channel in channels.values) { + for (final channel in _channelsController.value.values) { channel.state?.unreadCount = 0; } }), @@ -2672,8 +2683,8 @@ class ClientState { final isCurrentUser = event.user!.id == currentUser!.id; if (isCurrentUser && event.channel != null) { final eventChannel = event.channel!; + _deactivateChannel(eventChannel.cid); await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - channels[eventChannel.cid]?.dispose(); } }), ); @@ -2689,7 +2700,7 @@ class ClientState { .listen((Event event) async { final eventChannel = event.channel!; await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - channels[eventChannel.cid]?.dispose(); + _channelFor(eventChannel.cid)?.dispose(); }), ); } @@ -2704,7 +2715,7 @@ class ClientState { // Iterate through all the available channels and send the event // to be handled by the respective channel instances. - for (final cid in [...channels.keys]) { + for (final cid in [..._channelsController.value.keys]) { final channelEvent = event.copyWith(cid: cid); _client.handleEvent(channelEvent); } @@ -2875,29 +2886,65 @@ class ClientState { /// The current total unread messages count as a stream Stream get totalUnreadCountStream => _totalUnreadCountController.stream.distinct(); - /// The current list of channels in memory as a stream - Stream> get channelsStream => _channelsController.stream; + /// The channels in memory as a stream, leaving out the ones the current user + /// hid or was removed from. + Stream> get channelsStream { + return _channelsController.stream.map(_activeChannels); + } - /// The current list of channels in memory - Map get channels => _channelsController.value; + /// The channels in memory, leaving out the ones the current user hid or was + /// removed from. + Map get channels => _activeChannels(_channelsController.value); @internal set channels(Map newChannels) { + _inactiveCids.clear(); _channelsController.safeAdd(newChannels); } /// Adds a list of channels to the current list of cached channels @internal void addChannels(Map channelMap) { - final newChannels = {...channels, ...channelMap}; - channels = newChannels; + _inactiveCids.removeAll(channelMap.keys); + _channelsController.safeAdd({..._channelsController.value, ...channelMap}); } /// Removes the channel from the cached list of [channels] @internal void removeChannel(String channelCid) { - if (!channels.containsKey(channelCid)) return; - channels = {...channels}..remove(channelCid); + _inactiveCids.remove(channelCid); + if (!_channelsController.value.containsKey(channelCid)) return; + _channelsController.safeAdd({..._channelsController.value}..remove(channelCid)); + } + + // The live channel for [cid], including one the current user hid or was + // removed from. + Channel? _channelFor(String cid) => _channelsController.value[cid]; + + // Leaves the channel out of [channels] without disposing it, so code still + // holding it can keep using it. Watching it, a channels query returning it, + // or a channel.visible event brings it back. + void _deactivateChannel(String cid) { + if (!_channelsController.value.containsKey(cid)) return; + if (!_inactiveCids.add(cid)) return; + + _channelsController.safeAdd(_channelsController.value); + } + + // Puts a channel left out by [_deactivateChannel] back into [channels]. + void _reactivateChannel(String cid) { + if (!_inactiveCids.remove(cid)) return; + + _channelsController.safeAdd(_channelsController.value); + } + + Map _activeChannels(Map channels) { + if (_inactiveCids.isEmpty) return channels; + + return Map.unmodifiable({ + for (final MapEntry(:key, :value) in channels.entries) + if (!_inactiveCids.contains(key)) key: value, + }); } @internal @@ -2925,7 +2972,10 @@ class ClientState { } } + // Every live channel, including the ones left out of [channels]. final _channelsController = ImmutableMapBehaviorSubject.seeded(const {}); + // The channels the current user hid or was removed from. + final _inactiveCids = {}; final _currentUserController = BehaviorSubject(); final _usersController = ImmutableMapBehaviorSubject.seeded(const {}); final _unreadChannelsController = BehaviorSubject.seeded(0); @@ -2944,8 +2994,9 @@ class ClientState { _activeLiveLocationsController.close(); _locationExpirationScheduler.cancel(); + final remainingChannels = _channelsController.value.values; _channelsController.close(); - for (final channel in channels.values) { + for (final channel in remainingChannels) { channel.dispose(); // Dispose any remaining channels in memory. } } diff --git a/packages/stream_chat/lib/src/core/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart index 4d4ef8a3ba..7200544bb0 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -98,6 +98,9 @@ class ChannelState implements ComparableFieldProvider { Map toJson() => _$ChannelStateToJson(this); /// Creates a copy of [ChannelState] with specified attributes overridden. + /// + /// Passing null for [membership] or [draft] clears it, while leaving it out + /// keeps the current value. ChannelState copyWith({ ChannelModel? channel, List? messages, @@ -106,7 +109,7 @@ class ChannelState implements ComparableFieldProvider { int? watcherCount, List? watchers, List? read, - Member? membership, + Object? membership = _nullConst, Object? draft = _nullConst, List? pendingMessages, ChannelPushPreference? pushPreferences, @@ -119,7 +122,7 @@ class ChannelState implements ComparableFieldProvider { watcherCount: watcherCount ?? this.watcherCount, watchers: watchers ?? this.watchers, read: read ?? this.read, - membership: membership ?? this.membership, + membership: membership == _nullConst ? this.membership : membership as Member?, draft: draft == _nullConst ? this.draft : draft as Draft?, pendingMessages: pendingMessages ?? this.pendingMessages, pushPreferences: pushPreferences ?? this.pushPreferences, diff --git a/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart b/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart index 11b42fb5ef..cef0513a65 100644 --- a/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_state_mutations_test.dart @@ -59,6 +59,7 @@ void main() { removeTypingEvent: removeTypingEventCalls.add, removeWatcher: (watcher, {watcherCount}) => removeWatcherCalls.add((watcher, watcherCount)), removeMember: removeMemberCalls.add, + clearMembership: () {}, updateMember: updateMemberCalls.add, deleteMessagesFromUser: ({required userId, hardDelete = false, deletedAt}) async { deleteMessagesFromUserCalls.add((userId, hardDelete, deletedAt)); diff --git a/packages/stream_chat/test/src/client/client_hidden_and_left_channels_test.dart b/packages/stream_chat/test/src/client/client_hidden_and_left_channels_test.dart new file mode 100644 index 0000000000..eebea0de32 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_hidden_and_left_channels_test.dart @@ -0,0 +1,361 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + const apiKey = 'test-api-key'; + final currentUser = OwnUser(id: 'current-user'); + final otherUser = User(id: 'other-user'); + + late FakeChatApi api; + late StreamChatClient client; + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + + setUp(() { + api = FakeChatApi(); + client = StreamChatClient(apiKey, ws: FakeWebSocket(), chatApi: api); + client.state + ..currentUser = currentUser + ..subscribeToEvents(); + }); + + tearDown(() { + client.dispose(); + }); + + test('channel.hidden leaves the channel out of client.state.channels', () async { + _addChannel(client, 'messaging:hidden'); + + await _hide(client, 'messaging:hidden'); + + expect(client.state.channels, isNot(contains('messaging:hidden'))); + }); + + test('channel.hidden keeps the channel usable for code still holding it', () async { + final channel = _addChannel(client, 'messaging:hidden'); + + await _hide(client, 'messaging:hidden'); + + expect(channel.state, isNotNull); + }); + + test('channel.hidden deletes the channel from persistence', () async { + final persistence = MockPersistenceClient(); + when(() => persistence.deleteChannels(any())).thenAnswer((_) async {}); + when(() => persistence.getChannelThreads(any())).thenAnswer((_) async => {}); + client.chatPersistenceClient = persistence; + _addChannel(client, 'messaging:hidden'); + + await _hide(client, 'messaging:hidden'); + + verify(() => persistence.deleteChannels(['messaging:hidden'])).called(1); + }); + + test('channel.hidden marks the channel as hidden', () async { + final channel = _addChannel(client, 'messaging:hidden'); + + await _hide(client, 'messaging:hidden'); + + expect(channel.hidden, isTrue); + }); + + test('channel.visible marks a hidden channel as visible', () async { + final channel = _addChannel(client, 'messaging:hidden'); + await _hide(client, 'messaging:hidden'); + + client.handleEvent( + Event( + type: EventType.channelVisible, + cid: 'messaging:hidden', + channel: ChannelModel(cid: 'messaging:hidden'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.hidden, isFalse); + }); + + test('notification.removed_from_channel leaves the channel out of client.state.channels', () async { + _addChannel(client, 'messaging:left'); + + await _removeFromChannel(client, 'messaging:left', currentUser); + + expect(client.state.channels, isNot(contains('messaging:left'))); + }); + + test('notification.removed_from_channel keeps the channel usable for code still holding it', () async { + final channel = _addChannel(client, 'messaging:left'); + + await _removeFromChannel(client, 'messaging:left', currentUser); + + expect(channel.state, isNotNull); + }); + + test("notification.removed_from_channel clears the current user's membership", () async { + final channel = _addChannel(client, 'messaging:left', membership: _membershipOf(currentUser)); + + await _removeFromChannel(client, 'messaging:left', currentUser); + + expect(channel.state!.channelState.membership, isNull); + }); + + test('member.removed for the current user clears their membership', () async { + final channel = _addChannel(client, 'messaging:left', membership: _membershipOf(currentUser)); + + client.handleEvent( + Event( + type: EventType.memberRemoved, + cid: 'messaging:left', + channel: ChannelModel(cid: 'messaging:left'), + user: currentUser, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.membership, isNull); + }); + + test("member.removed for another user keeps the current user's membership", () async { + final channel = _addChannel(client, 'messaging:group', membership: _membershipOf(currentUser)); + + client.handleEvent( + Event( + type: EventType.memberRemoved, + cid: 'messaging:group', + channel: ChannelModel(cid: 'messaging:group'), + user: otherUser, + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.channelState.membership, isNotNull); + }); + + test('querying a channel whose response has no membership keeps the current one', () async { + final channel = _addChannel(client, 'messaging:group', membership: _membershipOf(currentUser)); + _stubQueryChannels(api, ['messaging:group']); + + await client.queryChannelsOnline(waitForConnect: false); + + expect(channel.state!.channelState.membership, isNotNull); + }); + + test('member.removed for another user keeps the channel in client.state.channels', () async { + _addChannel(client, 'messaging:group'); + + client.handleEvent( + Event( + type: EventType.memberRemoved, + cid: 'messaging:group', + channel: ChannelModel(cid: 'messaging:group'), + user: otherUser, + ), + ); + await Future.delayed(Duration.zero); + + expect(client.state.channels, contains('messaging:group')); + }); + + test('client.channel returns the same instance for a hidden channel', () async { + final channel = _addChannel(client, 'messaging:hidden'); + + await _hide(client, 'messaging:hidden'); + + expect(client.channel('messaging', id: 'hidden'), same(channel)); + }); + + test('watching a hidden channel puts it back in client.state.channels', () async { + final channel = _addChannel(client, 'messaging:hidden'); + await _hide(client, 'messaging:hidden'); + _stubQueryChannel(api, 'messaging:hidden'); + + await channel.watch(); + + expect(client.state.channels, contains('messaging:hidden')); + }); + + test('querying a hidden channel without watching it leaves it out of client.state.channels', () async { + final channel = _addChannel(client, 'messaging:hidden'); + await _hide(client, 'messaging:hidden'); + _stubQueryChannel(api, 'messaging:hidden'); + + await channel.query(messagesPagination: const PaginationParams(limit: 20)); + + expect(client.state.channels, isNot(contains('messaging:hidden'))); + }); + + test('querying channels brings back only the hidden channels the server returns', () async { + _addChannel(client, 'messaging:returned'); + _addChannel(client, 'messaging:still-hidden'); + await _hide(client, 'messaging:returned'); + await _hide(client, 'messaging:still-hidden'); + _stubQueryChannels(api, ['messaging:returned']); + + await client.queryChannelsOnline(waitForConnect: false); + + expect( + client.state.channels.keys, + allOf(contains('messaging:returned'), isNot(contains('messaging:still-hidden'))), + ); + }); + + test('querying channels reuses the instance of a hidden channel', () async { + final channel = _addChannel(client, 'messaging:hidden'); + await _hide(client, 'messaging:hidden'); + _stubQueryChannels(api, ['messaging:hidden']); + + final channels = await client.queryChannelsOnline(waitForConnect: false); + + expect(channels.single, same(channel)); + }); + + test('client.state.channels stays unmodifiable while a channel is hidden', () async { + _addChannel(client, 'messaging:visible'); + _addChannel(client, 'messaging:hidden'); + + await _hide(client, 'messaging:hidden'); + + expect(() => client.state.channels.clear(), throwsUnsupportedError); + }); + + test('disposing the client state disposes hidden channels too', () async { + final channel = _addChannel(client, 'messaging:hidden'); + await _hide(client, 'messaging:hidden'); + + client.state.dispose(); + + expect(channel.state, isNull); + }); + + test('channel.deleted disposes a hidden channel', () async { + final channel = _addChannel(client, 'messaging:hidden'); + await _hide(client, 'messaging:hidden'); + + client.handleEvent( + Event( + type: EventType.channelDeleted, + cid: 'messaging:hidden', + channel: ChannelModel(cid: 'messaging:hidden'), + ), + ); + await Future.delayed(Duration.zero); + + expect(channel.state, isNull); + }); + + test('channel.visible puts a hidden channel back in client.state.channels', () async { + _addChannel(client, 'messaging:hidden'); + await _hide(client, 'messaging:hidden'); + + client.handleEvent( + Event( + type: EventType.channelVisible, + cid: 'messaging:hidden', + channel: ChannelModel(cid: 'messaging:hidden'), + ), + ); + await Future.delayed(Duration.zero); + + expect(client.state.channels, contains('messaging:hidden')); + }); + + test("deleting a banned user's messages reaches a hidden channel", () async { + final bannedUser = User(id: 'banned-user'); + final channel = Channel.fromState( + client, + ChannelState( + channel: ChannelModel(cid: 'messaging:hidden'), + messages: [Message(id: 'banned-message', user: bannedUser)], + ), + ); + client.state.addChannels({'messaging:hidden': channel}); + await _hide(client, 'messaging:hidden'); + + client.handleEvent( + Event(type: EventType.userMessagesDeleted, user: bannedUser, hardDelete: true), + ); + await Future.delayed(Duration.zero); + + expect(channel.state!.messages, isEmpty); + }); +} + +Channel _addChannel(StreamChatClient client, String cid, {Member? membership}) { + final channel = Channel.fromState( + client, + ChannelState( + channel: ChannelModel(cid: cid), + membership: membership, + ), + ); + client.state.addChannels({cid: channel}); + return channel; +} + +Member _membershipOf(User user) => Member(userId: user.id, user: user); + +Future _hide(StreamChatClient client, String cid) async { + client.handleEvent( + Event( + type: EventType.channelHidden, + cid: cid, + channel: ChannelModel(cid: cid), + ), + ); + await Future.delayed(Duration.zero); +} + +Future _removeFromChannel(StreamChatClient client, String cid, User user) async { + client.handleEvent( + Event( + type: EventType.notificationRemovedFromChannel, + cid: cid, + channel: ChannelModel(cid: cid), + user: user, + ), + ); + await Future.delayed(Duration.zero); +} + +void _stubQueryChannel(FakeChatApi api, String cid) { + final [type, id] = cid.split(':'); + when( + () => api.channel.queryChannel( + type, + channelId: id, + channelData: any(named: 'channelData'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => ChannelState(channel: ChannelModel(cid: cid))); +} + +void _stubQueryChannels(FakeChatApi api, List cids) { + 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 = [ + for (final cid in cids) ChannelState(channel: ChannelModel(cid: cid)), + ], + ); +} diff --git a/packages/stream_chat/test/src/core/models/channel_state_test.dart b/packages/stream_chat/test/src/core/models/channel_state_test.dart index cb17dbddda..3827e92858 100644 --- a/packages/stream_chat/test/src/core/models/channel_state_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_state_test.dart @@ -100,6 +100,26 @@ void main() { expect(channelState.draft, equals(draft)); }); + test('copyWith keeps the membership when it is left out', () { + final membership = Member(userId: 'current-user'); + final channelState = ChannelState(membership: membership); + + expect(channelState.copyWith().membership, membership); + }); + + test('copyWith replaces the membership with the given one', () { + final channelState = ChannelState(membership: Member(userId: 'current-user')); + final updated = Member(userId: 'current-user', pinnedAt: DateTime(2026)); + + expect(channelState.copyWith(membership: updated).membership, updated); + }); + + test('copyWith clears the membership when given null', () { + final channelState = ChannelState(membership: Member(userId: 'current-user')); + + expect(channelState.copyWith(membership: null).membership, isNull); + }); + group('ComparableFieldProvider', () { test('should return ComparableField for channel.lastMessageAt', () { final channelState = createChannelState( diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f91d8ac3a5..0524ad3af1 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -16,6 +16,7 @@ 🐞 Fixed +- Fixed the channel page crashing after its channel is hidden or the user is removed from it, for example by blocking the other member of a direct message. - Fixed a web address written without `https://`, such as `youtube.com`, not being tappable in a message. - Fixed a web address right after a quote or Chinese or Japanese text not being tappable in a message. - Fixed `StreamThreadPage` hiding the composer for a deleted parent message that already has replies, which can still be replied to.