diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs
index 5d97928f..96d2162a 100644
--- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs
+++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs
@@ -1441,7 +1441,7 @@ private void OnRemovedFromChannelNotification(
sb.AppendLine($"{nameof(eventDto.Channel.Cid)}: {eventDto.Channel.Cid}");
_logs.Info(sb.ToString());
#endif
- var channel = _cache.TryCreateOrUpdate(eventDto.Channel, out var wasCreated);
+ var channel = _cache.TryCreateOrUpdate(eventDto.Channel);
#if STREAM_TESTS_ENABLED
sb.Length = 0;
@@ -1452,29 +1452,24 @@ private void OnRemovedFromChannelNotification(
_logs.Info(sb.ToString());
#endif
+ _cache.TryCreateOrUpdate(eventDto.User);
+ if (eventDto.Member != null && eventDto.Member.User == null && eventDto.User != null)
+ {
+ eventDto.Member.User = eventDto.User;
+ }
+
var member = _cache.TryCreateOrUpdate(eventDto.Member);
- _cache.TryCreateOrUpdate(eventDto.Member.User);
- if (!wasCreated)
+ // Watched channels receive member.removed -> IStreamChannel.MemberRemoved instead.
+ // The server may still deliver notification.removed_from_channel to the removed user.
+ if (channel.IsWatched)
{
- RemovedFromChannelAsMember?.Invoke(channel, member);
return;
}
- // Watch channel, otherwise WS events won't be received
- InternalGetOrCreateChannelAsync(channel.Type, channel.Id).ContinueWith(t =>
- {
- if (t.IsFaulted)
- {
- _logs.Error($"Failed to watch channel with type: {channel.Type} & id: {channel.Id} " +
- $"before triggering the {nameof(RemovedFromChannelAsMember)} event. Inspect the following exception: " +
- t.Exception);
- _logs.Exception(t.Exception);
- return;
- }
-
- RemovedFromChannelAsMember?.Invoke(channel, member);
- }, TaskScheduler.FromCurrentSynchronizationContext());
+ // Unlike notification.added_to_channel, do not watch here — the user was just removed
+ // and no longer has ReadChannel. The notification payload is sufficient to raise the event.
+ RemovedFromChannelAsMember?.Invoke(channel, member);
}
private void OnInvitedNotification(NotificationInvitedEventInternalDTO eventDto)
diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs
index 890cd1c9..c1561052 100644
--- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs
+++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs
@@ -507,58 +507,150 @@ void OnMessageReceived(IStreamChannel messageChannel, IStreamMessage message)
}
[UnityTest]
- public IEnumerator When_user_removed_from_not_watched_channel_expect_user_removed_from_channel_event()
+ public IEnumerator
+ When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only()
=> ConnectAndExecute(
- When_user_removed_from_not_watched_channel_expect_user_removed_from_channel_event_Async);
-
- private async Task When_user_removed_from_not_watched_channel_expect_user_removed_from_channel_event_Async()
+ When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only_Async);
+
+ ///
+ /// notification.removed_from_channel is delivered to the removed user when they are not
+ /// watching the channel. The stateful client surfaces that as
+ /// , not
+ /// .
+ /// Exercises the real server response through the public client API; subscribers must be
+ /// able to read without it being null.
+ ///
+ private async Task When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only_Async()
{
var channel = await CreateUniqueTempChannelAsync(watch: false);
- var receivedAddedEvent = false;
- var receivedRemovedEvent = false;
- IStreamChannelMember eventMember = null;
- IStreamChannel eventChannel = null;
+ await channel.AddMembersAsync(hideHistory: default, optionalMessage: default, Client.LocalUserData.User);
+ await WaitWhileFalseAsync(
+ () => channel.Members.Any(m => m.User?.Id == Client.LocalUserData.UserId),
+ description: "local user to appear in unwatched channel members after add");
- void OnAddedToChannelAsMember(IStreamChannel channel2, IStreamChannelMember member)
+ Assert.IsFalse(channel.IsWatched,
+ "Precondition: channel must not be watched so the server sends notification.removed_from_channel");
+
+ var removedFromChannelAsMemberFired = false;
+ var memberRemovedFired = false;
+ IStreamChannelMember removedMember = null;
+
+ void OnRemovedFromChannelAsMember(IStreamChannel channel2, IStreamChannelMember member)
{
if (channel2.Cid != channel.Cid)
{
return;
}
- receivedAddedEvent = true;
+ removedFromChannelAsMemberFired = true;
+ removedMember = member;
}
- Client.AddedToChannelAsMember += OnAddedToChannelAsMember;
+ void OnMemberRemoved(IStreamChannel channel2, IStreamChannelMember member)
+ {
+ if (channel2.Cid != channel.Cid)
+ {
+ return;
+ }
- await channel.AddMembersAsync(hideHistory: default, optionalMessage: default, Client.LocalUserData.User);
- await WaitWhileFalseAsync(() => receivedAddedEvent);
+ memberRemovedFired = true;
+ }
+
+ Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember;
+ channel.MemberRemoved += OnMemberRemoved;
+
+ await channel.RemoveMembersAsync(Client.LocalUserData.User);
+ await WaitWhileFalseAsync(() => removedFromChannelAsMemberFired,
+ description: "Client.RemovedFromChannelAsMember after removing local user from unwatched channel");
- void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember member2)
+ Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember;
+ channel.MemberRemoved -= OnMemberRemoved;
+
+ Assert.IsTrue(removedFromChannelAsMemberFired);
+ Assert.IsFalse(memberRemovedFired,
+ "IStreamChannel.MemberRemoved must not fire when the local user is removed from an unwatched channel");
+ Assert.IsNotNull(removedMember);
+ Assert.IsNotNull(removedMember.User,
+ "IStreamChannelMember.User must be populated on RemovedFromChannelAsMember");
+ Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User.Id);
+ Assert.AreEqual(Client.LocalUserData.User, removedMember.User);
+ }
+
+ [UnityTest]
+ public IEnumerator When_local_user_removed_from_watched_channel_expect_member_removed_only()
+ => ConnectAndExecute(When_local_user_removed_from_watched_channel_expect_member_removed_only_Async);
+
+ ///
+ /// member.removed is delivered to clients watching the channel. When the local user is
+ /// removed while watching, the stateful client surfaces
+ /// instead of
+ /// .
+ /// The server may still send notification.removed_from_channel alongside member.removed;
+ /// the SDK suppresses the stateful notification event when the channel is watched locally.
+ ///
+ private async Task When_local_user_removed_from_watched_channel_expect_member_removed_only_Async()
+ {
+ var otherClient = await GetConnectedOtherClientAsync();
+ var otherClientChannel = await CreateUniqueTempChannelAsync(watch: true, overrideClient: otherClient);
+
+ await otherClientChannel.AddMembersAsync(hideHistory: default, optionalMessage: default,
+ Client.LocalUserData.User);
+
+ var channel = await Client.GetOrCreateChannelWithIdAsync(otherClientChannel.Type, otherClientChannel.Id);
+
+ await WaitWhileFalseAsync(
+ () => channel.IsWatched && channel.Members.Any(m => m.User?.Id == Client.LocalUserData.UserId),
+ description: "local client to watch channel and have local user membership before removal");
+
+ Assert.IsTrue(channel.IsWatched,
+ "Precondition: channel must be watched so the server sends member.removed");
+
+ var removedFromChannelAsMemberFired = false;
+ var memberRemovedFired = false;
+ IStreamChannelMember removedMember = null;
+
+ void OnRemovedFromChannelAsMember(IStreamChannel channel2, IStreamChannelMember member)
{
- if (channel3.Cid != channel.Cid)
+ if (channel2.Cid != channel.Cid)
{
return;
}
- receivedRemovedEvent = true;
- eventMember = member2;
- eventChannel = channel3;
+ removedFromChannelAsMemberFired = true;
+ }
+
+ void OnMemberRemoved(IStreamChannel channel2, IStreamChannelMember member)
+ {
+ if (channel2.Cid != channel.Cid)
+ {
+ return;
+ }
+
+ memberRemovedFired = true;
+ removedMember = member;
}
Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember;
+ channel.MemberRemoved += OnMemberRemoved;
- await channel.RemoveMembersAsync(new IStreamUser[] { Client.LocalUserData.User });
- await WaitWhileFalseAsync(() => receivedRemovedEvent);
+ await otherClientChannel.RemoveMembersAsync(Client.LocalUserData.User);
+ await WaitWhileFalseAsync(() => memberRemovedFired,
+ description: "channel.MemberRemoved after other client removes local user from watched channel");
+
+ await Task.Delay(5000);
- Client.AddedToChannelAsMember -= OnAddedToChannelAsMember;
Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember;
+ channel.MemberRemoved -= OnMemberRemoved;
- Assert.IsTrue(receivedRemovedEvent);
- Assert.IsNotNull(eventChannel);
- Assert.IsNotNull(eventMember);
- Assert.AreEqual(Client.LocalUserData.User, eventMember.User);
+ Assert.IsTrue(memberRemovedFired);
+ Assert.IsFalse(removedFromChannelAsMemberFired,
+ "Client.RemovedFromChannelAsMember must not fire when the local user is removed from a watched channel");
+ Assert.IsNotNull(removedMember);
+ Assert.IsNotNull(removedMember.User,
+ "IStreamChannelMember.User must be populated on MemberRemoved");
+ Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User.Id);
+ Assert.AreEqual(Client.LocalUserData.User, removedMember.User);
}
}
}