Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 13 additions & 18 deletions Assets/Plugins/StreamChat/Core/StreamChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down
142 changes: 117 additions & 25 deletions Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// notification.removed_from_channel is delivered to the removed user when they are not
/// watching the channel. The stateful client surfaces that as
/// <see cref="IStreamChatClient.RemovedFromChannelAsMember"/>, not
/// <see cref="IStreamChannel.MemberRemoved"/>.
/// Exercises the real server response through the public client API; subscribers must be
/// able to read <see cref="IStreamChannelMember.User"/> without it being null.
/// </summary>
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);

/// <summary>
/// member.removed is delivered to clients watching the channel. When the local user is
/// removed while watching, the stateful client surfaces
/// <see cref="IStreamChannel.MemberRemoved"/> instead of
/// <see cref="IStreamChatClient.RemovedFromChannelAsMember"/>.
/// 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.
/// </summary>
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);
}
}
}
Expand Down
Loading