diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChannelTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChannelTests.cs index 14d977e..f40b9cc 100644 --- a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChannelTests.cs +++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChannelTests.cs @@ -374,6 +374,23 @@ public async Task TestChannelIsPresent() Assert.True(isPresent, "someChannel.IsUserPresent() doesn't return true for most recently joined channel!"); } + [Test] + public async Task TestChannelHasAndGetMember() + { + var someChannel = TestUtils.AssertOperation(await chat.CreatePublicConversation()); + await someChannel.Join(); + + await Task.Delay(4000); + + var hasMember = TestUtils.AssertOperation(await someChannel.HasMember(user.Id)); + Assert.True(hasMember, "someChannel.HasMember() doesn't return true for most recently joined channel!"); + + var getMember = TestUtils.AssertOperation(await someChannel.GetMember(user.Id)); + Assert.True(getMember.ChannelId == someChannel.Id, "Wrong GetMember() channel id"); + Assert.True(getMember.UserId == user.Id, "Wrong GetMember() user id"); + + } + [Test] public async Task TestChannelWhoIsPresent() { @@ -405,6 +422,38 @@ public async Task TestPresenceCallback() Assert.True(presenceReceived, "did not receive presence callback"); } + [Test] + public async Task TestFetchReadReceipts() + { + var someChannel = TestUtils.AssertOperation(await chat.CreatePublicConversation()); + await someChannel.Join(); + await Task.Delay(2500); + + var reset = new ManualResetEvent(false); + Message readMessage = null; + var messageValue = "READ MEEEE"; + someChannel.OnMessageReceived += message => + { + if (message.MessageText == messageValue) + { + readMessage = message; + reset.Set(); + } + }; + await someChannel.SendText(messageValue); + + var gotMessage = reset.WaitOne(20000); + Assert.True(gotMessage, "Never received message callback."); + + var membership = TestUtils.AssertOperation(await user.GetMembership(someChannel.Id)); + TestUtils.AssertOperation(await membership.SetLastReadMessage(readMessage)); + await Task.Delay(8000); + + var receipts = TestUtils.AssertOperation(await someChannel.GetReadReceipts()); + + Assert.True(receipts.Any(x => x.Key == readMessage.TimeToken && x.Value.Contains(user.Id))); + } + [Test] public async Task TestReportCallback() { diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatTests.cs index 6645431..83de7fb 100644 --- a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatTests.cs +++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatTests.cs @@ -13,13 +13,21 @@ public class ChatTests [SetUp] public async Task Setup() { - chat = TestUtils.AssertOperation(await Chat.CreateInstance(new PubnubChatConfig(storeUserActivityTimestamp: true), + chat = TestUtils.AssertOperation(await Chat.CreateInstance( + new PubnubChatConfig( + storeUserActivityTimestamp: true, + emitReadReceiptEvents:new Dictionary() + { + {"public", true}, + {"group", true}, + {"direct", true}, + }), new PNConfiguration(new UserId("chats_tests_user_fresh_3")) { PublishKey = PubnubTestsParameters.PublishKey, SubscribeKey = PubnubTestsParameters.SubscribeKey })); - channel = TestUtils.AssertOperation(await chat.CreatePublicConversation("chat_tests_channel_2")); + channel = TestUtils.AssertOperation(await chat.CreatePublicConversation()); currentUser = TestUtils.AssertOperation(await chat.GetCurrentUser()); await channel.Join(); await Task.Delay(3500); @@ -30,6 +38,8 @@ public async Task CleanUp() { await channel.Leave(); await Task.Delay(1000); + await channel.Delete(); + await Task.Delay(1000); chat.Destroy(); await Task.Delay(1000); } @@ -231,13 +241,9 @@ public async Task TestReadReceipts() await Task.Delay(2500); var receiptReset = new ManualResetEvent(false); - otherChatChannel.OnReadReceiptEvent += readReceipts => + otherChatChannel.OnReadReceiptEvent += readReceipt => { - if (readReceipts.Count == 0) - { - return; - } - Assert.True(readReceipts.Values.Any(x => x != null && x.Contains(currentUser.Id))); + Assert.True(readReceipt.UserId == currentUser.Id); receiptReset.Set(); }; await otherChatChannel.SendText("READ MEEEE"); @@ -248,6 +254,123 @@ public async Task TestReadReceipts() var receipt = receiptReset.WaitOne(15000); Assert.True(receipt); } + + [Test] + public async Task TestDisableReadReceiptsInChatConfig() + { + var otherUserId = "other_chat_user"; + var otherChat = TestUtils.AssertOperation(await Chat.CreateInstance(new PubnubChatConfig( + storeUserActivityTimestamp: true, + emitReadReceiptEvents: new Dictionary() + { + {"public", false} + }), + new PNConfiguration(new UserId(otherUserId)) + { + PublishKey = PubnubTestsParameters.PublishKey, + SubscribeKey = PubnubTestsParameters.SubscribeKey + })); + var otherChatChannel = TestUtils.AssertOperation(await otherChat.GetChannel(channel.Id)); + await otherChatChannel.Join(); + await Task.Delay(2500); + + channel.StreamReadReceipts(true); + await Task.Delay(2500); + + var receiptReset = new ManualResetEvent(false); + channel.OnReadReceiptEvent += readReceipt => + { + Assert.True(readReceipt.UserId == otherUserId); + receiptReset.Set(); + }; + await channel.SendText("READ MEEEE"); + + await Task.Delay(5000); + + await otherChat.MarkAllMessagesAsRead(filter:$"channel.id LIKE \"{channel.Id}\""); + var receipt = receiptReset.WaitOne(15000); + Assert.False(receipt, "Received read receipt even with config set not to send events"); + } + + [Test] + public async Task TestEnableReadReceiptsInChannelInstance() + { + var otherUserId = "other_chat_user"; + var otherChat = TestUtils.AssertOperation(await Chat.CreateInstance(new PubnubChatConfig( + storeUserActivityTimestamp: true, + emitReadReceiptEvents: new Dictionary() + { + {"public", false} + }), + new PNConfiguration(new UserId(otherUserId)) + { + PublishKey = PubnubTestsParameters.PublishKey, + SubscribeKey = PubnubTestsParameters.SubscribeKey + })); + var otherChatChannel = TestUtils.AssertOperation(await otherChat.GetChannel(channel.Id)); + TestUtils.AssertOperation(await otherChatChannel.Update(new ChatChannelData() + { EmitReadReceiptEvents = true })); + await Task.Delay(2500); + await otherChatChannel.Join(); + await Task.Delay(2500); + + channel.StreamReadReceipts(true); + await Task.Delay(2500); + + var receiptReset = new ManualResetEvent(false); + channel.OnReadReceiptEvent += readReceipt => + { + Assert.True(readReceipt.UserId == otherUserId); + receiptReset.Set(); + }; + await channel.SendText("READ MEEEE"); + + await Task.Delay(5000); + + await otherChat.MarkAllMessagesAsRead(filter:$"channel.id LIKE \"{channel.Id}\""); + var receipt = receiptReset.WaitOne(15000); + Assert.True(receipt, "Didn't receive read receipt even with channel instance set to emit them"); + } + + [Test] + public async Task TestDisableReadReceiptsInChannelInstance() + { + var otherUserId = "other_chat_user"; + var otherChat = TestUtils.AssertOperation(await Chat.CreateInstance(new PubnubChatConfig( + storeUserActivityTimestamp: true, + emitReadReceiptEvents: new Dictionary() + { + {"public", true} + }), + new PNConfiguration(new UserId(otherUserId)) + { + PublishKey = PubnubTestsParameters.PublishKey, + SubscribeKey = PubnubTestsParameters.SubscribeKey + })); + var otherChatChannel = TestUtils.AssertOperation(await otherChat.GetChannel(channel.Id)); + TestUtils.AssertOperation(await otherChatChannel.Update(new ChatChannelData() + { EmitReadReceiptEvents = false })); + await Task.Delay(2500); + await otherChatChannel.Join(); + await Task.Delay(2500); + + channel.StreamReadReceipts(true); + await Task.Delay(2500); + + var receiptReset = new ManualResetEvent(false); + channel.OnReadReceiptEvent += readReceipt => + { + Assert.True(readReceipt.UserId == otherUserId); + receiptReset.Set(); + }; + await channel.SendText("READ MEEEE"); + + await Task.Delay(5000); + + await otherChat.MarkAllMessagesAsRead(filter:$"channel.id LIKE \"{channel.Id}\""); + var receipt = receiptReset.WaitOne(15000); + Assert.False(receipt, "Received read receipt even with channel instance set not to send events"); + } [Test] public async Task TestCanI() diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MembershipTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MembershipTests.cs index 0796a0e..d5dfe2d 100644 --- a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MembershipTests.cs +++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MembershipTests.cs @@ -115,7 +115,7 @@ public async Task TestInviteMultiple() [Test] public async Task TestLastRead() { - var testChannel = TestUtils.AssertOperation(await chat.CreatePublicConversation("last_read_test_channel_57")); + var testChannel = TestUtils.AssertOperation(await chat.CreatePublicConversation()); await testChannel.Join(); await Task.Delay(4000); @@ -149,6 +149,9 @@ public async Task TestLastRead() var received = messageReceivedManual.WaitOne(90000); Assert.True(received); + + //Cleanup + await testChannel.Delete(); } [Test] diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MessageTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MessageTests.cs index 8f5bbca..c4e8edb 100644 --- a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MessageTests.cs +++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/MessageTests.cs @@ -247,6 +247,30 @@ public async Task TestMessageReactions() var reacted = manualReset.WaitOne(10000); Assert.True(reacted); } + + [Test] + public async Task TestNewMessageReactions() + { + var manualReset = new ManualResetEvent(false); + channel.OnMessageReceived += async message => + { + await message.ToggleReaction("happy"); + + await Task.Delay(3000); + + var has = message.HasUserReaction("happy"); + Assert.True(has); + var newReactions = message.MessageReactions(); + Assert.True(newReactions.Count == 1 + && newReactions.Any( + x => x is { Value: "happy", IsMine: true, UserIds.Count: 1 } + && x.UserIds.Contains(chat.PubnubInstance.GetCurrentUserId()))); + manualReset.Set(); + }; + await channel.SendText("a_message"); + var reacted = manualReset.WaitOne(10000); + Assert.True(reacted); + } [Test] public async Task TestMessageReport() diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/UserTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/UserTests.cs index c4a9cc7..12b1195 100644 --- a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/UserTests.cs +++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/UserTests.cs @@ -163,4 +163,20 @@ public async Task TestUserIsPresentOn() Assert.True(isOn, "user.IsPresentOn() doesn't return true for most recently joined channel!"); } + + [Test] + public async Task TestUserIsAndGetMember() + { + var someChannel = TestUtils.AssertOperation(await chat.CreatePublicConversation()); + await someChannel.Join(); + + await Task.Delay(4000); + + var isMember = TestUtils.AssertOperation(await user.IsMemberOn(someChannel.Id)); + Assert.True(isMember, "user.IsMemberOn() doesn't return true for most recently joined channel!"); + + var getMembership = TestUtils.AssertOperation(await user.GetMembership(someChannel.Id)); + Assert.True(getMembership.ChannelId == someChannel.Id, "Wrong GetMembership() channel id"); + Assert.True(getMembership.UserId == user.Id, "Wrong GetMembership() user id"); + } } \ No newline at end of file diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Channel.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Channel.cs index 0fd42b5..cc396a7 100644 --- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Channel.cs +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Channel.cs @@ -67,6 +67,32 @@ public class Channel : UniqueChatEntity /// /// public string Type => channelData.Type; + + /// + /// Returns whether this channel emits read receipt events when setting last read message. + /// You can set this value by calling Update() with an instance of ChatChannelData with + /// EmitReadReceiptEvents set to true/false. + /// If no value is provided in ChatChannelData the default behaviour is specified inside + /// PubnubChatConfig under EmitReadReceiptEvents + /// + public bool EmitsReadReceiptEvents { + get + { + var emit = channelData.EmitReadReceiptEvents; + if (emit == null) + { + if (chat.Config?.EmitReadReceiptEvents == null) + { + return false; + } + return chat.Config.EmitReadReceiptEvents.TryGetValue(Type, out var configValue) && configValue; + } + else + { + return emit.Value; + } + } + } /// /// Returns true if the Channel has been soft-deleted. @@ -84,6 +110,7 @@ public bool IsDeleted } protected ChatChannelData channelData; + internal ChatChannelData ChannelData => channelData; protected Subscription? subscription; @@ -171,7 +198,7 @@ public bool IsDeleted private Subscription typingEventsSubscription; public event Action> OnUsersTyping; private Subscription readReceiptsSubscription; - public event Action>> OnReadReceiptEvent; + public event Action<(string MessageTimetoken, string UserId)> OnReadReceiptEvent; private Subscription reportEventsSubscription; public event Action OnReportEvent; private Subscription customEventsSubscription; @@ -355,24 +382,36 @@ async delegate(Pubnub _, PNMessageResult m) { if (ChatParsers.TryParseEvent(chat, m, PubnubChatEventType.Receipt, out var readEvent)) { - var getMembers = await chat.GetChannelMemberships(Id).ConfigureAwait(false); - if (getMembers.Error) - { - return; - } - var members = getMembers.Result; - var outputDict = members.Memberships - .GroupBy(membership => membership.LastReadMessageTimeToken) - .ToDictionary( - g => g.Key, - g => g.Select(membership => membership.UserId).ToList() ?? new List() - ) ?? new Dictionary>(); - OnReadReceiptEvent?.Invoke(outputDict); + OnReadReceiptEvent?.Invoke((readEvent.Payload, readEvent.UserId)); chat.BroadcastAnyEvent(readEvent); } })); } + /// + /// Retrieves the current state of read receipts on this channel. + /// Each key in the output dictionary is a timetoken, and the value is a list of users + /// who have it set as their last read one. + /// + public async Task>>> GetReadReceipts() + { + var result = new ChatOperationResult>>("Channel.FetchReadReceipts()", chat); + var getMembers = await chat.GetChannelMemberships(Id).ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + var members = getMembers.Result; + var outputDict = members.Memberships + .GroupBy(membership => membership.LastReadMessageTimeToken) + .ToDictionary( + g => g.Key, + g => g.Select(membership => membership.UserId).ToList() ?? new List() + ) ?? new Dictionary>(); + result.Result = outputDict; + return result; + } + /// /// Sets whether to listen for typing events on this channel. /// @@ -735,7 +774,7 @@ public async Task Join(ChatMembershipData? membershipData = { return result; } - var joinMembership = new Membership(chat, currentUserId, Id, membershipData); + var joinMembership = new Membership(chat, currentUserId, Id, membershipData, channelData); var setLast = await joinMembership.SetLastReadMessageTimeToken(ChatUtils.TimeTokenNow()).ConfigureAwait(false); if (result.RegisterOperation(setLast)) { @@ -1231,6 +1270,39 @@ public async Task> GetMemberships(st return await chat.GetChannelMemberships(Id, filter, sort, limit, page).ConfigureAwait(false); } + /// + /// Checks whether a User with the provided ID is a member of this Channel. + /// + public async Task> HasMember(string userId) + { + var result = new ChatOperationResult("Channel.HasMember()", chat); + var getMembers = await chat.GetChannelMemberships(Id, filter:$"uuid.id == \"{userId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + result.Result = getMembers.Result.Memberships?.Count != 0; + return result; + } + + /// + /// Tries to fetch the Membership in this Channel for a provided userId. + /// + public async Task> GetMember(string userId) + { + var result = new ChatOperationResult("Channel.GetMember()", chat); + var getMembers = await chat.GetChannelMemberships(Id, filter:$"uuid.id == \"{userId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + if (getMembers.Result.Memberships is { Count: > 0 }) + { + result.Result = getMembers.Result.Memberships[0]; + } + return result; + } + /// /// Gets the list of the Membership objects which have a Status of "pending". /// diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Chat.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Chat.cs index 030e6f2..26dd299 100644 --- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Chat.cs +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Chat.cs @@ -20,6 +20,7 @@ namespace PubnubChatApi public class Chat { internal const string INTERNAL_MODERATION_PREFIX = "PUBNUB_INTERNAL_MODERATION"; + internal const string INTERNAL_DATA_PREFIX = "PN_INTERNAL_"; internal const string MESSAGE_THREAD_ID_PREFIX = "PUBNUB_INTERNAL_THREAD"; public Pubnub PubnubInstance { get; } @@ -213,7 +214,8 @@ private async Task> CreateConversatio List users, string channelId = "", ChatChannelData? channelData = null, - ChatMembershipData? membershipData = null) + ChatMembershipData? hostMembershipData = null, + List? inviteesMembershipData = null) { var result = new ChatOperationResult($"Chat.CreateConversation-{type}", this){Result = new CreatedChannelWrapper()}; @@ -238,7 +240,7 @@ private async Task> CreateConversatio return result; } - membershipData ??= new ChatMembershipData(); + hostMembershipData ??= new ChatMembershipData(); var currentUserId = PubnubInstance.GetCurrentUserId(); var setMembershipResult = await PubnubInstance.SetMemberships() .Uuid(currentUserId) @@ -254,9 +256,9 @@ private async Task> CreateConversatio .Channels(new List() { new () { Channel = channelId, - Custom = membershipData.CustomData, - Status = membershipData.Status, - Type = membershipData.Type + Custom = hostMembershipData.CustomData, + Status = hostMembershipData.Status, + Type = hostMembershipData.Type }}) .ExecuteAsync().ConfigureAwait(false); @@ -265,7 +267,7 @@ private async Task> CreateConversatio return result; } - var hostMembership = new Membership(this, currentUserId, channelId, membershipData); + var hostMembership = new Membership(this, currentUserId, channelId, hostMembershipData, updated.Result); result.Result.HostMembership = hostMembership; var channel = new Channel(this, channelId, channelData); @@ -273,7 +275,7 @@ private async Task> CreateConversatio if (type == "direct") { - var inviteMembership = await InviteToChannel(channelId, users[0].Id).ConfigureAwait(false); + var inviteMembership = await InviteToChannel(channelId, users[0].Id, inviteesMembershipData?[0]).ConfigureAwait(false); if (result.RegisterOperation(inviteMembership)) { return result; @@ -281,7 +283,7 @@ private async Task> CreateConversatio result.Result.InviteesMemberships = new List() { inviteMembership.Result }; }else if (type == "group") { - var inviteMembership = await InviteMultipleToChannel(channelId, users).ConfigureAwait(false); + var inviteMembership = await InviteMultipleToChannel(channelId, users, inviteesMembershipData).ConfigureAwait(false); if (result.RegisterOperation(inviteMembership)) { return result; @@ -297,13 +299,14 @@ private async Task> CreateConversatio /// The user to create a direct conversation with. /// Optional channel ID. If not provided, a new GUID will be used. /// Optional additional channel data. - /// Optional membership data for the conversation. + /// Optional host membership data for the conversation. + /// Optional invitees membership data for the conversation. /// A ChatOperationResult containing the created channel wrapper with channel and membership information. public async Task> CreateDirectConversation(User user, string channelId = "", - ChatChannelData? channelData = null, ChatMembershipData? membershipData = null) + ChatChannelData? channelData = null, ChatMembershipData? hostMembershipData = null, ChatMembershipData? inviteeMembershipData = null) { return await CreateConversation("direct", new List() { user }, channelId, channelData, - membershipData).ConfigureAwait(false); + hostMembershipData, inviteeMembershipData == null ? null : new List(){inviteeMembershipData}).ConfigureAwait(false); } /// @@ -312,13 +315,14 @@ public async Task> CreateDirectConver /// The list of users to include in the group conversation. /// Optional channel ID. If not provided, a new GUID will be used. /// Optional additional channel data. - /// Optional membership data for the conversation. + /// Optional host membership data for the conversation. + /// Optional invitee membership data for the conversation. /// A ChatOperationResult containing the created channel wrapper with channel and membership information. public async Task> CreateGroupConversation(List users, string channelId = "", - ChatChannelData? channelData = null, ChatMembershipData? membershipData = null) + ChatChannelData? channelData = null, ChatMembershipData? hostMembershipData = null, List? inviteesMembershipData = null) { return await CreateConversation("group", users, channelId, channelData, - membershipData).ConfigureAwait(false); + hostMembershipData, inviteesMembershipData).ConfigureAwait(false); } /// @@ -326,8 +330,9 @@ public async Task> CreateGroupConvers /// /// The ID of the channel to invite the user to. /// The ID of the user to invite. + /// Optional - membership data for the created invitee Membership. /// A ChatOperationResult containing the created membership for the invited user. - public async Task> InviteToChannel(string channelId, string userId) + public async Task> InviteToChannel(string channelId, string userId, ChatMembershipData? membershipData = null) { var result = new ChatOperationResult("Chat.InviteToChannel()", this); //Check if already a member first @@ -357,11 +362,9 @@ public async Task> InviteToChannel(string channe new() { Channel = channelId, - Status = "pending" - //TODO: these too here? - //TODO: again, should ChatMembershipData from Create(...)Channel also be passed here? - /*Custom = , - Type = */ + Status = "pending", + Type = membershipData?.Type, + Custom = membershipData?.CustomData } }).ExecuteAsync().ConfigureAwait(false); @@ -370,20 +373,21 @@ public async Task> InviteToChannel(string channe return result; } - var newMataData = setMemberships.Result.Memberships?.FirstOrDefault(x => x.ChannelMetadata.Channel == channelId)? + var newMetaData = setMemberships.Result.Memberships?.FirstOrDefault(x => x.ChannelMetadata.Channel == channelId)? .ChannelMetadata; - if (newMataData != null) + if (newMetaData != null) { - channel.Result.UpdateLocalData(newMataData); + channel.Result.UpdateLocalData(newMetaData); } var inviteEventPayload = $"{{\"channelType\": \"{channel.Result.Type}\", \"channelId\": {channelId}}}"; await EmitEvent(PubnubChatEventType.Invite, userId, inviteEventPayload).ConfigureAwait(false); - + + var channelData = newMetaData ?? new ChatChannelData(); var newMembership = new Membership(this, userId, channelId, new ChatMembershipData() { Status = "pending" - }); + }, channelData); await newMembership.SetLastReadMessageTimeToken(ChatUtils.TimeTokenNow()).ConfigureAwait(false); result.Result = newMembership; @@ -395,15 +399,41 @@ public async Task> InviteToChannel(string channe /// /// The ID of the channel to invite users to. /// The list of users to invite. + /// Optional - membership datas for the created invitee Memberships. /// A ChatOperationResult containing a list of created memberships for the invited users. - public async Task>> InviteMultipleToChannel(string channelId, List users) + public async Task>> InviteMultipleToChannel(string channelId, List users, List? membershipsData = null) { var result = new ChatOperationResult>("Chat.InviteMultipleToChannel()", this) { Result = new List() }; + if (membershipsData != null && membershipsData.Count != users.Count) + { + result.Error = true; + result.Exception = new PNException("Users and MembershipsData have different sizes!"); + return result; + } var channel = await GetChannel(channelId).ConfigureAwait(false); if (result.RegisterOperation(channel)) { return result; } + var members = new List(); + if (membershipsData == null) + { + members = users.Select(x => new PNChannelMember() + { Uuid = x.Id, Status = "pending" }).ToList(); + } + else + { + for (int i = 0; i < users.Count; i++) + { + members.Add(new PNChannelMember() + { + Status = "pending", + Uuid = users[i].Id, + Custom = membershipsData[i].CustomData, + Type = membershipsData[i].Type + }); + } + } var inviteResponse = await PubnubInstance.SetChannelMembers().Channel(channelId) .Include( new[] { @@ -415,8 +445,7 @@ public async Task>> InviteMultipleToChannel PNChannelMemberField.UUID_TYPE, PNChannelMemberField.UUID_STATUS }) - //TODO: again, should ChatMembershipData from Create(...)Channel also be passed here? - .Uuids(users.Select(x => new PNChannelMember() { Custom = x.CustomData, Uuid = x.Id, Status = "pending"}).ToList()) + .Uuids(members) .ExecuteAsync().ConfigureAwait(false); if (result.RegisterOperation(inviteResponse)) @@ -431,7 +460,7 @@ public async Task>> InviteMultipleToChannel { continue; } - var newMembership = new Membership(this, userId, channelId, channelMember); + var newMembership = new Membership(this, userId, channelId, channelMember, channel.Result.ChannelData); await newMembership.SetLastReadMessageTimeToken(ChatUtils.TimeTokenNow()).ConfigureAwait(false); result.Result.Add(newMembership); @@ -1183,7 +1212,7 @@ public async Task> GetUserMembership CustomData = membershipResult.Custom, Status = membershipResult.Status, Type = membershipResult.Type - })); + }, membershipResult.ChannelMetadata)); } result.Result = new MembersResponseWrapper() { @@ -1257,6 +1286,11 @@ public async Task> GetChannelMembers { return result; } + var getChannel = await GetChannel(channelId).ConfigureAwait(false); + if (result.RegisterOperation(getChannel)) + { + return result; + } var memberships = new List(); foreach (var channelMemberResult in getResult.Result.ChannelMembers) @@ -1266,7 +1300,7 @@ public async Task> GetChannelMembers CustomData = channelMemberResult.Custom, Status = channelMemberResult.Status, Type = channelMemberResult.Type - })); + }, getChannel.Result.ChannelData)); } result.Result = new MembersResponseWrapper() { @@ -1372,8 +1406,11 @@ public async Task> MarkAllMessage } foreach (var membership in memberships) { - await EmitEvent(PubnubChatEventType.Receipt, membership.ChannelId, - $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false); + if (membership.EmitReadReceiptEvents) + { + await EmitEvent(PubnubChatEventType.Receipt, membership.ChannelId, + $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false); + } } result.Result = new MarkMessagesAsReadWrapper() { diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/ChatChannelData.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/ChatChannelData.cs index 48babc9..98c8b9b 100644 --- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/ChatChannelData.cs +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/ChatChannelData.cs @@ -14,6 +14,8 @@ namespace PubnubChatApi /// public class ChatChannelData { + internal static string RECEIPTS_FLAG => $"{Chat.INTERNAL_DATA_PREFIX}{"EmitReadReceipts"}"; + public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public Dictionary CustomData { get; set; } = new (); @@ -21,13 +23,37 @@ public class ChatChannelData public string Status { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; + public bool? EmitReadReceiptEvents + { + get + { + if (CustomData == null || !CustomData.TryGetValue(RECEIPTS_FLAG, out var value)) + { + return null; + } + return (bool)value; + } + set + { + if (value != null) + { + CustomData ??= new Dictionary(); + CustomData[RECEIPTS_FLAG] = value.Value; + } + else + { + CustomData.Remove(RECEIPTS_FLAG); + } + } + } + public static implicit operator ChatChannelData(PNChannelMetadataResult metadataResult) { return new ChatChannelData() { Name = metadataResult.Name, Description = metadataResult.Description, - CustomData = metadataResult.Custom, + CustomData = metadataResult.Custom ?? new Dictionary(), Status = metadataResult.Status, Updated = metadataResult.Updated, Type = metadataResult.Type diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/MessageReaction.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/MessageReaction.cs new file mode 100644 index 0000000..54ae2fc --- /dev/null +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/MessageReaction.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace PubnubChatApi +{ + /// + /// Contains the data for a single type of reaction to a specific message. + /// + public class MessageReaction + { + /// + /// Type of reaction, e.g. an emoji + /// + public string Value { get; set; } = string.Empty; + /// + /// Whether the reaction was also made by the current user + /// + public bool IsMine {get; set;} + /// + /// All the users who gave this reaction + /// + public List UserIds {get; set;} = new(); + /// + /// Amount of reactions - equal to the count of UserIDs. + /// + public int Count => UserIds.Count; + } +} \ No newline at end of file diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/PubnubChatConfig.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/PubnubChatConfig.cs index c0d6843..949a515 100644 --- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/PubnubChatConfig.cs +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Data/PubnubChatConfig.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using PubnubApi; namespace PubnubChatApi @@ -31,10 +32,12 @@ public class RateLimitPerChannel public int StoreUserActivityInterval { get; } public bool SyncMutedUsers { get; } public PushNotificationsConfig PushNotifications { get; } + public Dictionary EmitReadReceiptEvents { get; } public PubnubChatConfig(int typingTimeout = 5000, int typingTimeoutDifference = 1000, int rateLimitFactor = 2, RateLimitPerChannel rateLimitPerChannel = null, bool storeUserActivityTimestamp = false, - int storeUserActivityInterval = 60000, bool syncMutedUsers = false, PushNotificationsConfig pushNotifications = null) + int storeUserActivityInterval = 60000, bool syncMutedUsers = false, PushNotificationsConfig pushNotifications = null, + Dictionary? emitReadReceiptEvents = null) { RateLimitsPerChannel = rateLimitPerChannel ?? new RateLimitPerChannel(); RateLimitFactor = rateLimitFactor; @@ -44,6 +47,12 @@ public PubnubChatConfig(int typingTimeout = 5000, int typingTimeoutDifference = TypingTimeoutDifference = typingTimeoutDifference; SyncMutedUsers = syncMutedUsers; PushNotifications = pushNotifications ?? new PushNotificationsConfig(); + EmitReadReceiptEvents = emitReadReceiptEvents ?? new Dictionary() + { + {"public", false}, + {"group", true}, + {"direct", true}, + }; } } } \ No newline at end of file diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Membership.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Membership.cs index 1bdcdba..cf10f74 100644 --- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Membership.cs +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Membership.cs @@ -80,11 +80,37 @@ public class Membership : UniqueChatEntity protected override string UpdateChannelId => ChannelId; - internal Membership(Chat chat, string userId, string channelId, ChatMembershipData membershipData) : base(chat, userId+channelId) + //TODO: currently this is only set in constructor so it doesn't react to channel type changes + internal bool EmitReadReceiptEvents { get; private set; } + + internal Membership(Chat chat, string userId, string channelId, ChatMembershipData membershipData, ChatChannelData channelData) : base(chat, userId+channelId) { UserId = userId; ChannelId = channelId; UpdateLocalData(membershipData); + SetReadReceiptEventsEmission(channelData); + } + + private async void SetReadReceiptEventsEmission(ChatChannelData channelData) + { + //Per-channel-instance value set + if (channelData.EmitReadReceiptEvents != null) + { + EmitReadReceiptEvents = channelData.EmitReadReceiptEvents.Value; + } + //Using the per-channel-type value from config + else + { + var channelType = channelData.Type; + if (string.IsNullOrEmpty(channelType) || chat.Config.EmitReadReceiptEvents == null) + { + return; + } + if (chat.Config.EmitReadReceiptEvents.TryGetValue(channelType, out var emit)) + { + EmitReadReceiptEvents = emit; + } + } } internal void UpdateLocalData(ChatMembershipData newData) @@ -101,7 +127,6 @@ protected override SubscribeCallback CreateUpdateListener() UpdateLocalData(updatedData); OnMembershipUpdated?.Invoke(this); OnUpdate?.Invoke(this, changeType); - } }); } @@ -235,8 +260,11 @@ public async Task SetLastReadMessageTimeToken(string timeTo { return result; } - result.RegisterOperation(await chat.EmitEvent(PubnubChatEventType.Receipt, ChannelId, - $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false)); + if (EmitReadReceiptEvents) + { + result.RegisterOperation(await chat.EmitEvent(PubnubChatEventType.Receipt, ChannelId, + $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false)); + } return result; } diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Message.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Message.cs index 855e84f..7a2c550 100644 --- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Message.cs +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Message.cs @@ -293,6 +293,28 @@ public static void StreamUpdatesOn(List messages, Action + /// Returns reactions added to this message formatted into a list of MessageReaction objects. + /// + public List MessageReactions() + { + var rawReactions = Reactions; + var rawReactionsByType = rawReactions.GroupBy(x => x.Value) + .ToDictionary(x => x.Key, y => y.Select(z => z).ToList()); + var reactions = new List(); + foreach (var kvp in rawReactionsByType) + { + var userIds = kvp.Value.Select(x => x.UserId).ToList(); + reactions.Add(new MessageReaction() + { + Value = kvp.Key, + IsMine = userIds.Contains(chat.PubnubInstance.GetCurrentUserId()), + UserIds = userIds + }); + } + return reactions; + } + /// /// Edits the text of the message. /// diff --git a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/User.cs b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/User.cs index 91e0d4b..10653bf 100644 --- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/User.cs +++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/User.cs @@ -743,5 +743,39 @@ public async Task> GetMemberships(st { return await chat.GetUserMemberships(Id, filter, sort, limit, page).ConfigureAwait(false); } + + + /// + /// Checks whether this User is a member of the Channel with the provided channelId. + /// + public async Task> IsMemberOn(string channelId) + { + var result = new ChatOperationResult("User.IsMemberOn()", chat); + var getMembers = await chat.GetUserMemberships(Id, filter:$"channel.id == \"{channelId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + result.Result = getMembers.Result.Memberships?.Count != 0; + return result; + } + + /// + /// Tries to fetch the Membership of this User for a provided channelId. + /// + public async Task> GetMembership(string channelId) + { + var result = new ChatOperationResult("User.GetMembership()", chat); + var getMembers = await chat.GetUserMemberships(Id, filter:$"channel.id == \"{channelId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + if (getMembers.Result.Memberships is { Count: > 0 }) + { + result.Result = getMembers.Result.Memberships[0]; + } + return result; + } } } \ No newline at end of file diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Channel.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Channel.cs index 0fd42b5..cc396a7 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Channel.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Channel.cs @@ -67,6 +67,32 @@ public class Channel : UniqueChatEntity /// /// public string Type => channelData.Type; + + /// + /// Returns whether this channel emits read receipt events when setting last read message. + /// You can set this value by calling Update() with an instance of ChatChannelData with + /// EmitReadReceiptEvents set to true/false. + /// If no value is provided in ChatChannelData the default behaviour is specified inside + /// PubnubChatConfig under EmitReadReceiptEvents + /// + public bool EmitsReadReceiptEvents { + get + { + var emit = channelData.EmitReadReceiptEvents; + if (emit == null) + { + if (chat.Config?.EmitReadReceiptEvents == null) + { + return false; + } + return chat.Config.EmitReadReceiptEvents.TryGetValue(Type, out var configValue) && configValue; + } + else + { + return emit.Value; + } + } + } /// /// Returns true if the Channel has been soft-deleted. @@ -84,6 +110,7 @@ public bool IsDeleted } protected ChatChannelData channelData; + internal ChatChannelData ChannelData => channelData; protected Subscription? subscription; @@ -171,7 +198,7 @@ public bool IsDeleted private Subscription typingEventsSubscription; public event Action> OnUsersTyping; private Subscription readReceiptsSubscription; - public event Action>> OnReadReceiptEvent; + public event Action<(string MessageTimetoken, string UserId)> OnReadReceiptEvent; private Subscription reportEventsSubscription; public event Action OnReportEvent; private Subscription customEventsSubscription; @@ -355,24 +382,36 @@ async delegate(Pubnub _, PNMessageResult m) { if (ChatParsers.TryParseEvent(chat, m, PubnubChatEventType.Receipt, out var readEvent)) { - var getMembers = await chat.GetChannelMemberships(Id).ConfigureAwait(false); - if (getMembers.Error) - { - return; - } - var members = getMembers.Result; - var outputDict = members.Memberships - .GroupBy(membership => membership.LastReadMessageTimeToken) - .ToDictionary( - g => g.Key, - g => g.Select(membership => membership.UserId).ToList() ?? new List() - ) ?? new Dictionary>(); - OnReadReceiptEvent?.Invoke(outputDict); + OnReadReceiptEvent?.Invoke((readEvent.Payload, readEvent.UserId)); chat.BroadcastAnyEvent(readEvent); } })); } + /// + /// Retrieves the current state of read receipts on this channel. + /// Each key in the output dictionary is a timetoken, and the value is a list of users + /// who have it set as their last read one. + /// + public async Task>>> GetReadReceipts() + { + var result = new ChatOperationResult>>("Channel.FetchReadReceipts()", chat); + var getMembers = await chat.GetChannelMemberships(Id).ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + var members = getMembers.Result; + var outputDict = members.Memberships + .GroupBy(membership => membership.LastReadMessageTimeToken) + .ToDictionary( + g => g.Key, + g => g.Select(membership => membership.UserId).ToList() ?? new List() + ) ?? new Dictionary>(); + result.Result = outputDict; + return result; + } + /// /// Sets whether to listen for typing events on this channel. /// @@ -735,7 +774,7 @@ public async Task Join(ChatMembershipData? membershipData = { return result; } - var joinMembership = new Membership(chat, currentUserId, Id, membershipData); + var joinMembership = new Membership(chat, currentUserId, Id, membershipData, channelData); var setLast = await joinMembership.SetLastReadMessageTimeToken(ChatUtils.TimeTokenNow()).ConfigureAwait(false); if (result.RegisterOperation(setLast)) { @@ -1231,6 +1270,39 @@ public async Task> GetMemberships(st return await chat.GetChannelMemberships(Id, filter, sort, limit, page).ConfigureAwait(false); } + /// + /// Checks whether a User with the provided ID is a member of this Channel. + /// + public async Task> HasMember(string userId) + { + var result = new ChatOperationResult("Channel.HasMember()", chat); + var getMembers = await chat.GetChannelMemberships(Id, filter:$"uuid.id == \"{userId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + result.Result = getMembers.Result.Memberships?.Count != 0; + return result; + } + + /// + /// Tries to fetch the Membership in this Channel for a provided userId. + /// + public async Task> GetMember(string userId) + { + var result = new ChatOperationResult("Channel.GetMember()", chat); + var getMembers = await chat.GetChannelMemberships(Id, filter:$"uuid.id == \"{userId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + if (getMembers.Result.Memberships is { Count: > 0 }) + { + result.Result = getMembers.Result.Memberships[0]; + } + return result; + } + /// /// Gets the list of the Membership objects which have a Status of "pending". /// diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Chat.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Chat.cs index 030e6f2..26dd299 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Chat.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Chat.cs @@ -20,6 +20,7 @@ namespace PubnubChatApi public class Chat { internal const string INTERNAL_MODERATION_PREFIX = "PUBNUB_INTERNAL_MODERATION"; + internal const string INTERNAL_DATA_PREFIX = "PN_INTERNAL_"; internal const string MESSAGE_THREAD_ID_PREFIX = "PUBNUB_INTERNAL_THREAD"; public Pubnub PubnubInstance { get; } @@ -213,7 +214,8 @@ private async Task> CreateConversatio List users, string channelId = "", ChatChannelData? channelData = null, - ChatMembershipData? membershipData = null) + ChatMembershipData? hostMembershipData = null, + List? inviteesMembershipData = null) { var result = new ChatOperationResult($"Chat.CreateConversation-{type}", this){Result = new CreatedChannelWrapper()}; @@ -238,7 +240,7 @@ private async Task> CreateConversatio return result; } - membershipData ??= new ChatMembershipData(); + hostMembershipData ??= new ChatMembershipData(); var currentUserId = PubnubInstance.GetCurrentUserId(); var setMembershipResult = await PubnubInstance.SetMemberships() .Uuid(currentUserId) @@ -254,9 +256,9 @@ private async Task> CreateConversatio .Channels(new List() { new () { Channel = channelId, - Custom = membershipData.CustomData, - Status = membershipData.Status, - Type = membershipData.Type + Custom = hostMembershipData.CustomData, + Status = hostMembershipData.Status, + Type = hostMembershipData.Type }}) .ExecuteAsync().ConfigureAwait(false); @@ -265,7 +267,7 @@ private async Task> CreateConversatio return result; } - var hostMembership = new Membership(this, currentUserId, channelId, membershipData); + var hostMembership = new Membership(this, currentUserId, channelId, hostMembershipData, updated.Result); result.Result.HostMembership = hostMembership; var channel = new Channel(this, channelId, channelData); @@ -273,7 +275,7 @@ private async Task> CreateConversatio if (type == "direct") { - var inviteMembership = await InviteToChannel(channelId, users[0].Id).ConfigureAwait(false); + var inviteMembership = await InviteToChannel(channelId, users[0].Id, inviteesMembershipData?[0]).ConfigureAwait(false); if (result.RegisterOperation(inviteMembership)) { return result; @@ -281,7 +283,7 @@ private async Task> CreateConversatio result.Result.InviteesMemberships = new List() { inviteMembership.Result }; }else if (type == "group") { - var inviteMembership = await InviteMultipleToChannel(channelId, users).ConfigureAwait(false); + var inviteMembership = await InviteMultipleToChannel(channelId, users, inviteesMembershipData).ConfigureAwait(false); if (result.RegisterOperation(inviteMembership)) { return result; @@ -297,13 +299,14 @@ private async Task> CreateConversatio /// The user to create a direct conversation with. /// Optional channel ID. If not provided, a new GUID will be used. /// Optional additional channel data. - /// Optional membership data for the conversation. + /// Optional host membership data for the conversation. + /// Optional invitees membership data for the conversation. /// A ChatOperationResult containing the created channel wrapper with channel and membership information. public async Task> CreateDirectConversation(User user, string channelId = "", - ChatChannelData? channelData = null, ChatMembershipData? membershipData = null) + ChatChannelData? channelData = null, ChatMembershipData? hostMembershipData = null, ChatMembershipData? inviteeMembershipData = null) { return await CreateConversation("direct", new List() { user }, channelId, channelData, - membershipData).ConfigureAwait(false); + hostMembershipData, inviteeMembershipData == null ? null : new List(){inviteeMembershipData}).ConfigureAwait(false); } /// @@ -312,13 +315,14 @@ public async Task> CreateDirectConver /// The list of users to include in the group conversation. /// Optional channel ID. If not provided, a new GUID will be used. /// Optional additional channel data. - /// Optional membership data for the conversation. + /// Optional host membership data for the conversation. + /// Optional invitee membership data for the conversation. /// A ChatOperationResult containing the created channel wrapper with channel and membership information. public async Task> CreateGroupConversation(List users, string channelId = "", - ChatChannelData? channelData = null, ChatMembershipData? membershipData = null) + ChatChannelData? channelData = null, ChatMembershipData? hostMembershipData = null, List? inviteesMembershipData = null) { return await CreateConversation("group", users, channelId, channelData, - membershipData).ConfigureAwait(false); + hostMembershipData, inviteesMembershipData).ConfigureAwait(false); } /// @@ -326,8 +330,9 @@ public async Task> CreateGroupConvers /// /// The ID of the channel to invite the user to. /// The ID of the user to invite. + /// Optional - membership data for the created invitee Membership. /// A ChatOperationResult containing the created membership for the invited user. - public async Task> InviteToChannel(string channelId, string userId) + public async Task> InviteToChannel(string channelId, string userId, ChatMembershipData? membershipData = null) { var result = new ChatOperationResult("Chat.InviteToChannel()", this); //Check if already a member first @@ -357,11 +362,9 @@ public async Task> InviteToChannel(string channe new() { Channel = channelId, - Status = "pending" - //TODO: these too here? - //TODO: again, should ChatMembershipData from Create(...)Channel also be passed here? - /*Custom = , - Type = */ + Status = "pending", + Type = membershipData?.Type, + Custom = membershipData?.CustomData } }).ExecuteAsync().ConfigureAwait(false); @@ -370,20 +373,21 @@ public async Task> InviteToChannel(string channe return result; } - var newMataData = setMemberships.Result.Memberships?.FirstOrDefault(x => x.ChannelMetadata.Channel == channelId)? + var newMetaData = setMemberships.Result.Memberships?.FirstOrDefault(x => x.ChannelMetadata.Channel == channelId)? .ChannelMetadata; - if (newMataData != null) + if (newMetaData != null) { - channel.Result.UpdateLocalData(newMataData); + channel.Result.UpdateLocalData(newMetaData); } var inviteEventPayload = $"{{\"channelType\": \"{channel.Result.Type}\", \"channelId\": {channelId}}}"; await EmitEvent(PubnubChatEventType.Invite, userId, inviteEventPayload).ConfigureAwait(false); - + + var channelData = newMetaData ?? new ChatChannelData(); var newMembership = new Membership(this, userId, channelId, new ChatMembershipData() { Status = "pending" - }); + }, channelData); await newMembership.SetLastReadMessageTimeToken(ChatUtils.TimeTokenNow()).ConfigureAwait(false); result.Result = newMembership; @@ -395,15 +399,41 @@ public async Task> InviteToChannel(string channe /// /// The ID of the channel to invite users to. /// The list of users to invite. + /// Optional - membership datas for the created invitee Memberships. /// A ChatOperationResult containing a list of created memberships for the invited users. - public async Task>> InviteMultipleToChannel(string channelId, List users) + public async Task>> InviteMultipleToChannel(string channelId, List users, List? membershipsData = null) { var result = new ChatOperationResult>("Chat.InviteMultipleToChannel()", this) { Result = new List() }; + if (membershipsData != null && membershipsData.Count != users.Count) + { + result.Error = true; + result.Exception = new PNException("Users and MembershipsData have different sizes!"); + return result; + } var channel = await GetChannel(channelId).ConfigureAwait(false); if (result.RegisterOperation(channel)) { return result; } + var members = new List(); + if (membershipsData == null) + { + members = users.Select(x => new PNChannelMember() + { Uuid = x.Id, Status = "pending" }).ToList(); + } + else + { + for (int i = 0; i < users.Count; i++) + { + members.Add(new PNChannelMember() + { + Status = "pending", + Uuid = users[i].Id, + Custom = membershipsData[i].CustomData, + Type = membershipsData[i].Type + }); + } + } var inviteResponse = await PubnubInstance.SetChannelMembers().Channel(channelId) .Include( new[] { @@ -415,8 +445,7 @@ public async Task>> InviteMultipleToChannel PNChannelMemberField.UUID_TYPE, PNChannelMemberField.UUID_STATUS }) - //TODO: again, should ChatMembershipData from Create(...)Channel also be passed here? - .Uuids(users.Select(x => new PNChannelMember() { Custom = x.CustomData, Uuid = x.Id, Status = "pending"}).ToList()) + .Uuids(members) .ExecuteAsync().ConfigureAwait(false); if (result.RegisterOperation(inviteResponse)) @@ -431,7 +460,7 @@ public async Task>> InviteMultipleToChannel { continue; } - var newMembership = new Membership(this, userId, channelId, channelMember); + var newMembership = new Membership(this, userId, channelId, channelMember, channel.Result.ChannelData); await newMembership.SetLastReadMessageTimeToken(ChatUtils.TimeTokenNow()).ConfigureAwait(false); result.Result.Add(newMembership); @@ -1183,7 +1212,7 @@ public async Task> GetUserMembership CustomData = membershipResult.Custom, Status = membershipResult.Status, Type = membershipResult.Type - })); + }, membershipResult.ChannelMetadata)); } result.Result = new MembersResponseWrapper() { @@ -1257,6 +1286,11 @@ public async Task> GetChannelMembers { return result; } + var getChannel = await GetChannel(channelId).ConfigureAwait(false); + if (result.RegisterOperation(getChannel)) + { + return result; + } var memberships = new List(); foreach (var channelMemberResult in getResult.Result.ChannelMembers) @@ -1266,7 +1300,7 @@ public async Task> GetChannelMembers CustomData = channelMemberResult.Custom, Status = channelMemberResult.Status, Type = channelMemberResult.Type - })); + }, getChannel.Result.ChannelData)); } result.Result = new MembersResponseWrapper() { @@ -1372,8 +1406,11 @@ public async Task> MarkAllMessage } foreach (var membership in memberships) { - await EmitEvent(PubnubChatEventType.Receipt, membership.ChannelId, - $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false); + if (membership.EmitReadReceiptEvents) + { + await EmitEvent(PubnubChatEventType.Receipt, membership.ChannelId, + $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false); + } } result.Result = new MarkMessagesAsReadWrapper() { diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/ChatChannelData.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/ChatChannelData.cs index 48babc9..98c8b9b 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/ChatChannelData.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/ChatChannelData.cs @@ -14,6 +14,8 @@ namespace PubnubChatApi /// public class ChatChannelData { + internal static string RECEIPTS_FLAG => $"{Chat.INTERNAL_DATA_PREFIX}{"EmitReadReceipts"}"; + public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public Dictionary CustomData { get; set; } = new (); @@ -21,13 +23,37 @@ public class ChatChannelData public string Status { get; set; } = string.Empty; public string Type { get; set; } = string.Empty; + public bool? EmitReadReceiptEvents + { + get + { + if (CustomData == null || !CustomData.TryGetValue(RECEIPTS_FLAG, out var value)) + { + return null; + } + return (bool)value; + } + set + { + if (value != null) + { + CustomData ??= new Dictionary(); + CustomData[RECEIPTS_FLAG] = value.Value; + } + else + { + CustomData.Remove(RECEIPTS_FLAG); + } + } + } + public static implicit operator ChatChannelData(PNChannelMetadataResult metadataResult) { return new ChatChannelData() { Name = metadataResult.Name, Description = metadataResult.Description, - CustomData = metadataResult.Custom, + CustomData = metadataResult.Custom ?? new Dictionary(), Status = metadataResult.Status, Updated = metadataResult.Updated, Type = metadataResult.Type diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/MessageReaction.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/MessageReaction.cs new file mode 100644 index 0000000..54ae2fc --- /dev/null +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/MessageReaction.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace PubnubChatApi +{ + /// + /// Contains the data for a single type of reaction to a specific message. + /// + public class MessageReaction + { + /// + /// Type of reaction, e.g. an emoji + /// + public string Value { get; set; } = string.Empty; + /// + /// Whether the reaction was also made by the current user + /// + public bool IsMine {get; set;} + /// + /// All the users who gave this reaction + /// + public List UserIds {get; set;} = new(); + /// + /// Amount of reactions - equal to the count of UserIDs. + /// + public int Count => UserIds.Count; + } +} \ No newline at end of file diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/MessageReaction.cs.meta b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/MessageReaction.cs.meta new file mode 100644 index 0000000..9da4319 --- /dev/null +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/MessageReaction.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a343b2892c80b84b8ec20ec1ba19561 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/PubnubChatConfig.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/PubnubChatConfig.cs index c0d6843..949a515 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/PubnubChatConfig.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Data/PubnubChatConfig.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using PubnubApi; namespace PubnubChatApi @@ -31,10 +32,12 @@ public class RateLimitPerChannel public int StoreUserActivityInterval { get; } public bool SyncMutedUsers { get; } public PushNotificationsConfig PushNotifications { get; } + public Dictionary EmitReadReceiptEvents { get; } public PubnubChatConfig(int typingTimeout = 5000, int typingTimeoutDifference = 1000, int rateLimitFactor = 2, RateLimitPerChannel rateLimitPerChannel = null, bool storeUserActivityTimestamp = false, - int storeUserActivityInterval = 60000, bool syncMutedUsers = false, PushNotificationsConfig pushNotifications = null) + int storeUserActivityInterval = 60000, bool syncMutedUsers = false, PushNotificationsConfig pushNotifications = null, + Dictionary? emitReadReceiptEvents = null) { RateLimitsPerChannel = rateLimitPerChannel ?? new RateLimitPerChannel(); RateLimitFactor = rateLimitFactor; @@ -44,6 +47,12 @@ public PubnubChatConfig(int typingTimeout = 5000, int typingTimeoutDifference = TypingTimeoutDifference = typingTimeoutDifference; SyncMutedUsers = syncMutedUsers; PushNotifications = pushNotifications ?? new PushNotificationsConfig(); + EmitReadReceiptEvents = emitReadReceiptEvents ?? new Dictionary() + { + {"public", false}, + {"group", true}, + {"direct", true}, + }; } } } \ No newline at end of file diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Membership.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Membership.cs index 1bdcdba..cf10f74 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Membership.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Membership.cs @@ -80,11 +80,37 @@ public class Membership : UniqueChatEntity protected override string UpdateChannelId => ChannelId; - internal Membership(Chat chat, string userId, string channelId, ChatMembershipData membershipData) : base(chat, userId+channelId) + //TODO: currently this is only set in constructor so it doesn't react to channel type changes + internal bool EmitReadReceiptEvents { get; private set; } + + internal Membership(Chat chat, string userId, string channelId, ChatMembershipData membershipData, ChatChannelData channelData) : base(chat, userId+channelId) { UserId = userId; ChannelId = channelId; UpdateLocalData(membershipData); + SetReadReceiptEventsEmission(channelData); + } + + private async void SetReadReceiptEventsEmission(ChatChannelData channelData) + { + //Per-channel-instance value set + if (channelData.EmitReadReceiptEvents != null) + { + EmitReadReceiptEvents = channelData.EmitReadReceiptEvents.Value; + } + //Using the per-channel-type value from config + else + { + var channelType = channelData.Type; + if (string.IsNullOrEmpty(channelType) || chat.Config.EmitReadReceiptEvents == null) + { + return; + } + if (chat.Config.EmitReadReceiptEvents.TryGetValue(channelType, out var emit)) + { + EmitReadReceiptEvents = emit; + } + } } internal void UpdateLocalData(ChatMembershipData newData) @@ -101,7 +127,6 @@ protected override SubscribeCallback CreateUpdateListener() UpdateLocalData(updatedData); OnMembershipUpdated?.Invoke(this); OnUpdate?.Invoke(this, changeType); - } }); } @@ -235,8 +260,11 @@ public async Task SetLastReadMessageTimeToken(string timeTo { return result; } - result.RegisterOperation(await chat.EmitEvent(PubnubChatEventType.Receipt, ChannelId, - $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false)); + if (EmitReadReceiptEvents) + { + result.RegisterOperation(await chat.EmitEvent(PubnubChatEventType.Receipt, ChannelId, + $"{{\"messageTimetoken\": \"{timeToken}\"}}").ConfigureAwait(false)); + } return result; } diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Message.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Message.cs index 855e84f..7a2c550 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Message.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/Message.cs @@ -293,6 +293,28 @@ public static void StreamUpdatesOn(List messages, Action + /// Returns reactions added to this message formatted into a list of MessageReaction objects. + /// + public List MessageReactions() + { + var rawReactions = Reactions; + var rawReactionsByType = rawReactions.GroupBy(x => x.Value) + .ToDictionary(x => x.Key, y => y.Select(z => z).ToList()); + var reactions = new List(); + foreach (var kvp in rawReactionsByType) + { + var userIds = kvp.Value.Select(x => x.UserId).ToList(); + reactions.Add(new MessageReaction() + { + Value = kvp.Key, + IsMine = userIds.Contains(chat.PubnubInstance.GetCurrentUserId()), + UserIds = userIds + }); + } + return reactions; + } + /// /// Edits the text of the message. /// diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/User.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/User.cs index 91e0d4b..10653bf 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/User.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Runtime/PubnubChatApi/Entities/User.cs @@ -743,5 +743,39 @@ public async Task> GetMemberships(st { return await chat.GetUserMemberships(Id, filter, sort, limit, page).ConfigureAwait(false); } + + + /// + /// Checks whether this User is a member of the Channel with the provided channelId. + /// + public async Task> IsMemberOn(string channelId) + { + var result = new ChatOperationResult("User.IsMemberOn()", chat); + var getMembers = await chat.GetUserMemberships(Id, filter:$"channel.id == \"{channelId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + result.Result = getMembers.Result.Memberships?.Count != 0; + return result; + } + + /// + /// Tries to fetch the Membership of this User for a provided channelId. + /// + public async Task> GetMembership(string channelId) + { + var result = new ChatOperationResult("User.GetMembership()", chat); + var getMembers = await chat.GetUserMemberships(Id, filter:$"channel.id == \"{channelId}\"").ConfigureAwait(false); + if (result.RegisterOperation(getMembers)) + { + return result; + } + if (getMembers.Result.Memberships is { Count: > 0 }) + { + result.Result = getMembers.Result.Memberships[0]; + } + return result; + } } } \ No newline at end of file diff --git a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Samples~/PubnubChatConfigAsset/PubnubChatConfigAsset.cs b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Samples~/PubnubChatConfigAsset/PubnubChatConfigAsset.cs index 3d1ec33..8b237a1 100644 --- a/unity-chat/PubnubChatUnity/Assets/PubnubChat/Samples~/PubnubChatConfigAsset/PubnubChatConfigAsset.cs +++ b/unity-chat/PubnubChatUnity/Assets/PubnubChat/Samples~/PubnubChatConfigAsset/PubnubChatConfigAsset.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using PubnubChatApi; using UnityEngine; @@ -6,6 +8,13 @@ namespace PubnubChatApi [CreateAssetMenu(fileName = "PubnubChatConfigAsset", menuName = "PubNub/PubNub Chat Config Asset")] public class PubnubChatConfigAsset : ScriptableObject { + [System.Serializable] + public struct EmitReadReceiptSetting + { + public string ChannelType; + public bool EmitEvent; + } + [field: SerializeField] public int TypingTimeout { get; private set; } = 5000; [field: SerializeField] public int TypingTimeoutDifference { get; private set; } = 1000; [field: SerializeField] public int RateLimitFactor { get; private set; } @@ -15,6 +24,14 @@ public class PubnubChatConfigAsset : ScriptableObject [field: SerializeField] public bool SyncMutedUsers { get; private set; } = false; [field: SerializeField] public PubnubChatConfig.PushNotificationsConfig PushNotifications { get; private set; } = new (); + [field: SerializeField] + public List EmitReadReceiptEvents { get; private set; } = new() + { + new EmitReadReceiptSetting() { ChannelType = "public", EmitEvent = false }, + new EmitReadReceiptSetting() { ChannelType = "group", EmitEvent = true }, + new EmitReadReceiptSetting() { ChannelType = "direct", EmitEvent = true }, + }; + public static implicit operator PubnubChatConfig(PubnubChatConfigAsset asset) { return new PubnubChatConfig( @@ -24,7 +41,8 @@ public static implicit operator PubnubChatConfig(PubnubChatConfigAsset asset) storeUserActivityInterval: asset.StoreUserActivityInterval, storeUserActivityTimestamp: asset.StoreUserActivityTimestamp, syncMutedUsers: asset.SyncMutedUsers, - pushNotifications: asset.PushNotifications); + pushNotifications: asset.PushNotifications, + emitReadReceiptEvents: asset.EmitReadReceiptEvents?.ToDictionary(x => x.ChannelType, y=> y.EmitEvent)); } } } \ No newline at end of file diff --git a/unity-chat/PubnubChatUnity/Assets/Snippets/ReadReceiptsMessageSample.cs b/unity-chat/PubnubChatUnity/Assets/Snippets/ReadReceiptsMessageSample.cs index a92195f..3fc6250 100644 --- a/unity-chat/PubnubChatUnity/Assets/Snippets/ReadReceiptsMessageSample.cs +++ b/unity-chat/PubnubChatUnity/Assets/Snippets/ReadReceiptsMessageSample.cs @@ -56,21 +56,60 @@ public static async Task ReadReceiptsExample() } // the event handler - void OnReadHandler(Dictionary> readEvent) + void OnReadHandler((string MessageTimetoken, string UserId) readEvent) { // print the message details to the console - foreach (var kvp in readEvent) + Debug.Log( + $"Received a read receipt event for timetoken {readEvent.MessageTimetoken}" + + $" from user {readEvent.UserId}"); + // you can add additional logic here, such as confirming receipt to the user or processing the message further + } + // snippet.end + } + + public static async Task ReadReceiptsConfigExample() + { + // snippet.read_receipts_config_example + // set read receipt emission rules per channel type when creating Chat object + var chatConfig = new PubnubChatConfig() + { + EmitReadReceiptEvents = { - var channel = kvp.Key; - foreach (var user in kvp.Value) - { - Debug.Log( - $"Received a read receipt event on channel {channel}" + - $" from user {user}"); - } + { "public", false }, + { "group", true }, + { "direct", true }, + { "some_custom_type", true }, } - // you can add additional logic here, such as confirming receipt to the user or processing the message further + }; + var pubnubConfig = new PNConfiguration(new UserId("some_user")) + { + PublishKey = "your_publish_key", + SubscribeKey = "your_subscribe_key", + }; + var createChat = await UnityChat.CreateInstance(chatConfig, pubnubConfig); + if (createChat.Error) + { + Debug.LogError($"Error when trying to create Chat instance: {createChat.Exception.Message}"); + return; } + var chat = createChat.Result; + // snippet.end + } + + public static async Task ReadReceiptsInstanceExample() + { + // snippet.read_receipts_instance_example + var getChannel = await chat.GetChannel("some_channel"); + if (getChannel.Error) + { + Debug.LogError($"Error when trying to get channel: {getChannel.Exception.Message}"); + return; + } + var channel = getChannel.Result; + + // this means that even if PubnubChatConfig has emitting read receipt events set to false + // for this type of channel, this instance will emit them + await channel.Update(new ChatChannelData() { EmitReadReceiptEvents = true }); // snippet.end } }