Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5fd710f
test(e2e): add e2e_app, port native e2e suites, one app process per C…
testableapple Oct 23, 2026
0b8aa6d
chore(repo): ignore e2e mock server, Allure results and allurectl in …
testableapple Oct 23, 2026
b4979e4
test(repo): link every e2e test to its Allure TestOps case
testableapple Oct 23, 2026
b71afe2
Merge remote-tracking branch 'origin/master' into test/e2e-native-parity
testableapple Oct 23, 2026
b03e78e
test(repo): unmount the e2e app before resetting the client, verify c…
testableapple Oct 23, 2026
3bb8bd3
ci(repo): run PR e2e on API 36, use a preinstalled NDK, retry failed …
testableapple Oct 23, 2026
e7510b3
test(repo): retype into a field that ignored the text
testableapple Oct 23, 2026
1e75428
test(repo): keep the copied message id in the e2e app, bound the vid…
testableapple Oct 23, 2026
4b7a5fa
Merge branch 'master' into test/e2e-native-parity
testableapple Oct 23, 2026
8d9cef2
test(repo): scroll until a message is on screen, stop when the emulat…
testableapple Oct 23, 2026
0e4da80
ci(repo): give the e2e app's Gradle the sample app's memory settings
testableapple Oct 23, 2026
4a66a48
Bump timeout
testableapple Oct 24, 2026
ab90f31
test(repo): restart a dead emulator during the e2e run
testableapple Oct 24, 2026
c883599
test(repo): time-limit e2e runs so a dead emulator can't hang the job
testableapple Oct 24, 2026
87586b9
fix(repo): look up timeout through sh so the lane runs on Linux
testableapple Oct 24, 2026
d42a93a
ci(repo): split the e2e coverage nightly into 4 batches
testableapple Oct 25, 2026
8e73290
ci(repo): give the e2e runners memory headroom
testableapple Oct 25, 2026
b6acb2a
test(repo): retry the tap that opens the image gallery
testableapple Oct 25, 2026
d0d5fad
test(repo): assert the token date and signature policy (#3123)
xsahil03x Oct 27, 2026
bdf15fd
fix(ui): keep the footer of a sent message before a failed one (#3122)
xsahil03x Oct 27, 2026
9f02ab0
test(repo): address review comments on the e2e suite
testableapple Oct 27, 2026
83ef7be
fix(llc): keep a channel usable after the user hides it or is removed…
xsahil03x Oct 27, 2026
92929ab
fix(llc): tidy the hidden and left channel fix after review
xsahil03x Oct 27, 2026
c8b2f8e
Merge remote-tracking branch 'origin/master' into sahil/flu-1085-open…
xsahil03x Oct 27, 2026
a94f64b
Merge remote-tracking branch 'origin/master' into sahil/flu-1085-open…
xsahil03x Oct 28, 2026
5de92bc
Merge remote-tracking branch 'origin/master' into sahil/flu-1085-open…
xsahil03x Oct 28, 2026
fa379a1
Merge remote-tracking branch 'origin/master' into sahil/flu-1085-open…
xsahil03x Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion e2e_app/integration_test/channel_actions_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 0 additions & 2 deletions e2e_app/integration_test/moderation_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
7 changes: 7 additions & 0 deletions packages/stream_chat/lib/src/client/channel/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ChannelClientState {
removeTypingEvent: _removeTypingEvent,
removeWatcher: _removeWatcher,
removeMember: _removeMember,
clearMembership: _clearMembership,
updateMember: _updateMember,
deleteMessagesFromUser: _deleteMessagesFromUser,
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -121,6 +125,8 @@ class ChannelEventHandler {
_onMemberAdded(event);
case EventType.memberRemoved:
_onMemberRemoved(event);
case EventType.notificationRemovedFromChannel:
_mutations.onCurrentUserRemoved(event.user!);
}
}

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class ChannelStateMutations {
required this._removeTypingEvent,
required this._removeWatcher,
required this._removeMember,
required this._clearMembership,
required this._updateMember,
required this._deleteMessagesFromUser,
});
Expand All @@ -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<void> Function({
required String userId,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
87 changes: 69 additions & 18 deletions packages/stream_chat/lib/src/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -979,12 +979,14 @@ class StreamChatClient {
MapEntry<Map<String, Channel>, List<Channel>> _mapChannelStateToChannel(
List<ChannelState> channelStates,
) {
final channels = {...state.channels};
final channels = <String, Channel>{};
final newChannels = <Channel>[];
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);
Expand Down Expand Up @@ -1298,8 +1300,8 @@ class StreamChatClient {
String? id,
Map<String, Object?>? 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);
}
Expand Down Expand Up @@ -2576,6 +2578,7 @@ class ClientState {
_listenChannelLeft();
_listenChannelDeleted();
_listenChannelHidden();
_listenChannelVisible();
// endregion

// region USER EVENTS
Expand Down Expand Up @@ -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);
}),
);
}
Expand Down Expand Up @@ -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;
}
}),
Expand All @@ -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();
}
}),
);
Expand All @@ -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();
}),
);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -2875,29 +2886,65 @@ class ClientState {
/// The current total unread messages count as a stream
Stream<int> get totalUnreadCountStream => _totalUnreadCountController.stream.distinct();

/// The current list of channels in memory as a stream
Stream<Map<String, Channel>> get channelsStream => _channelsController.stream;
/// The channels in memory as a stream, leaving out the ones the current user
/// hid or was removed from.
Stream<Map<String, Channel>> get channelsStream {
return _channelsController.stream.map(_activeChannels);
}

/// The current list of channels in memory
Map<String, Channel> get channels => _channelsController.value;
/// The channels in memory, leaving out the ones the current user hid or was
/// removed from.
Map<String, Channel> get channels => _activeChannels(_channelsController.value);

@internal
set channels(Map<String, Channel> newChannels) {
_inactiveCids.clear();
_channelsController.safeAdd(newChannels);
}

/// Adds a list of channels to the current list of cached channels
@internal
void addChannels(Map<String, Channel> 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<String, Channel> _activeChannels(Map<String, Channel> channels) {
if (_inactiveCids.isEmpty) return channels;

return Map.unmodifiable({
for (final MapEntry(:key, :value) in channels.entries)
if (!_inactiveCids.contains(key)) key: value,
});
}

@internal
Expand Down Expand Up @@ -2925,7 +2972,10 @@ class ClientState {
}
}

// Every live channel, including the ones left out of [channels].
final _channelsController = ImmutableMapBehaviorSubject<String, Channel>.seeded(const {});
// The channels the current user hid or was removed from.
final _inactiveCids = <String>{};
final _currentUserController = BehaviorSubject<OwnUser?>();
final _usersController = ImmutableMapBehaviorSubject<String, User>.seeded(const {});
final _unreadChannelsController = BehaviorSubject<int>.seeded(0);
Expand All @@ -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.
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/stream_chat/lib/src/core/models/channel_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ class ChannelState implements ComparableFieldProvider {
Map<String, dynamic> 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<Message>? messages,
Expand All @@ -106,7 +109,7 @@ class ChannelState implements ComparableFieldProvider {
int? watcherCount,
List<User>? watchers,
List<Read>? read,
Member? membership,
Object? membership = _nullConst,
Object? draft = _nullConst,
List<Message>? pendingMessages,
ChannelPushPreference? pushPreferences,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading