diff --git a/.pubnub.yml b/.pubnub.yml
index be0f2bc..a725069 100644
--- a/.pubnub.yml
+++ b/.pubnub.yml
@@ -1,6 +1,13 @@
---
-version: v1.2.0
+version: v1.3.0
changelog:
+ - date: 2025-12-08
+ version: v1.3.0
+ changes:
+ - type: feature
+ text: "Integrated Pubnub files support into Chat functionality - messages sent via MessageDraft or directly from Channel.SendText() can now have file attachments."
+ - type: feature
+ text: "Integrated Pubnub push notifications support into Chat - PubnubChatConfig now has a PushNotifications section and Channel entities can be registered to receive push notifications."
- date: 2025-11-24
version: v1.2.0
changes:
diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatEventTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatEventTests.cs
index e003f8e..d23a55d 100644
--- a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatEventTests.cs
+++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/ChatEventTests.cs
@@ -28,7 +28,7 @@ public async Task Setup()
[TearDown]
public async Task CleanUp()
{
- channel.Leave();
+ await channel.Leave();
await Task.Delay(3000);
chat.Destroy();
await Task.Delay(3000);
diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/FilesTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/FilesTests.cs
new file mode 100644
index 0000000..303f669
--- /dev/null
+++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/FilesTests.cs
@@ -0,0 +1,203 @@
+using PubnubApi;
+using PubnubChatApi;
+using Channel = PubnubChatApi.Channel;
+
+namespace PubNubChatApi.Tests;
+
+[TestFixture]
+public class FilesTests
+{
+ private const string FILE_NAME = "fileupload.txt";
+ private const string FILE_LOCATION = @"fileupload.txt";
+ private const string LARGE_FILE_NAME = "file_large.png";
+ private const string LARGE_FILE_LOCATION = @"file_large.png";
+
+ private Chat chat;
+ private Channel channel;
+ private User user;
+
+ [SetUp]
+ public async Task Setup()
+ {
+ chat = TestUtils.AssertOperation(await Chat.CreateInstance(new PubnubChatConfig(),
+ new PNConfiguration(new UserId("file_tests_user"))
+ {
+ PublishKey = PubnubTestsParameters.PublishKey,
+ SubscribeKey = PubnubTestsParameters.SubscribeKey,
+ }));
+ channel = TestUtils.AssertOperation(await chat.CreatePublicConversation("file_tests_channel"));
+ user = TestUtils.AssertOperation(await chat.GetCurrentUser());
+ await channel.Join();
+ await Task.Delay(3500);
+ }
+
+ [TearDown]
+ public async Task CleanUp()
+ {
+ await ClearChannelOfFiles();
+ await channel.Leave();
+ await Task.Delay(3000);
+ chat.Destroy();
+ await Task.Delay(3000);
+ }
+
+ private async Task ClearChannelOfFiles()
+ {
+ var files = await channel.GetFiles();
+ if (files.Error)
+ {
+ Assert.Fail($"Error in files cleanup: {files.Exception.Message}");
+ return;
+ }
+
+ foreach (var file in files.Result.Files)
+ {
+ var result = await channel.DeleteFile(file.Id, file.Name);
+ if (result.Error)
+ {
+ Assert.Fail($"Error in files cleanup: {files.Exception.Message}");
+ }
+ }
+ }
+
+ [Test]
+ public async Task TestFileUploadInMessage()
+ {
+ //Cleanup: delete files from channel
+ await ClearChannelOfFiles();
+
+ await channel.Join();
+ await Task.Delay(250);
+
+ var receivedMessageReset = new ManualResetEvent(false);
+ Message receivedMessage = null;
+ channel.OnMessageReceived += message =>
+ {
+ if (message.MessageText == "FILE")
+ {
+ receivedMessage = message;
+ receivedMessageReset.Set();
+ }
+ };
+
+ //Add file to SendTextParams and send message
+ TestUtils.AssertOperation(await channel.SendText("FILE", new SendTextParams()
+ {
+ Files =
+ [
+ new ChatInputFile()
+ {
+ Name = FILE_NAME,
+ Type = "text",
+ Source = FILE_LOCATION
+ }
+ ]
+ }));
+
+ //Receive message and check message.Files
+ var received = receivedMessageReset.WaitOne(10000);
+ Assert.True(received, "Did not receive message with file at all!");
+ Assert.True(receivedMessage != null, "receivedMessage was null!");
+ Assert.True(receivedMessage.Files != null, "receivedMessage.Files was null!");
+ Assert.True(receivedMessage.Files.Count == 1,
+ $"receivedMessage.Files.Count was {receivedMessage.Files.Count} instead of 1!");
+ var receivedFile = receivedMessage.Files[0];
+ Assert.True(receivedFile.Name == FILE_NAME,
+ $"Expected file name \"the_file\" but got \"{receivedFile.Name}\"");
+ Assert.True(receivedFile.Type == "text", $"Expected file type \"text\" but got \"{receivedFile.Type}\"");
+ Assert.True(!string.IsNullOrEmpty(receivedFile.Id), "File ID is empty");
+ Assert.True(!string.IsNullOrEmpty(receivedFile.Url), "File URL is empty");
+
+ //Check channel.GetFiles() for the file and check if data matches with the one from message.Files
+ var channelFiles = TestUtils.AssertOperation(await channel.GetFiles());
+ Assert.True(
+ channelFiles.Files.Any(x =>
+ x.Id == receivedFile.Id && x.Name == receivedFile.Name && x.Url == receivedFile.Url),
+ "Did not find message file in channel.GetFiles()!");
+ }
+
+ [Test]
+ public async Task TestFileUploadInMessageDraft()
+ {
+ //Cleanup: delete files from channel
+ await ClearChannelOfFiles();
+
+ await channel.Join();
+ await Task.Delay(250);
+
+ var receivedMessageReset = new ManualResetEvent(false);
+ Message receivedMessage = null;
+ channel.OnMessageReceived += message =>
+ {
+ if (message.MessageText == "FILE")
+ {
+ receivedMessage = message;
+ receivedMessageReset.Set();
+ }
+ };
+
+ //Add file to SendTextParams in a MessageDraft and send message
+ var messageDraft = channel.CreateMessageDraft();
+ messageDraft.InsertText(0, "FILE");
+ messageDraft.Files.Add(new ChatInputFile()
+ {
+ Name = FILE_NAME,
+ Type = "text",
+ Source = FILE_LOCATION
+ });
+
+ TestUtils.AssertOperation(await messageDraft.Send());
+
+ //Receive message and check message.Files
+ var received = receivedMessageReset.WaitOne(10000);
+ Assert.True(received, "Did not receive message with file at all!");
+ Assert.True(receivedMessage != null, "receivedMessage was null!");
+ Assert.True(receivedMessage.Files != null, "receivedMessage.Files was null!");
+ Assert.True(receivedMessage.Files.Count == 1,
+ $"receivedMessage.Files.Count was {receivedMessage.Files.Count} instead of 1!");
+ var receivedFile = receivedMessage.Files[0];
+ Assert.True(receivedFile.Name == FILE_NAME,
+ $"Expected file name \"the_file\" but got \"{receivedFile.Name}\"");
+ Assert.True(receivedFile.Type == "text", $"Expected file type \"text\" but got \"{receivedFile.Type}\"");
+ Assert.True(!string.IsNullOrEmpty(receivedFile.Id), "File ID is empty");
+ Assert.True(!string.IsNullOrEmpty(receivedFile.Url), "File URL is empty");
+
+ //Check channel.GetFiles() for the file and check if data matches with the one from message.Files
+ var channelFiles = TestUtils.AssertOperation(await channel.GetFiles());
+ Assert.True(
+ channelFiles.Files.Any(x =>
+ x.Id == receivedFile.Id && x.Name == receivedFile.Name && x.Url == receivedFile.Url),
+ "Did not find message file in channel.GetFiles()!");
+ }
+
+ [Test]
+ public async Task TestFileUploadErrorHandling()
+ {
+ var receivedMessageReset = new ManualResetEvent(false);
+ channel.OnMessageReceived += message =>
+ {
+ if (message.MessageText == "FILE TOO BIG")
+ {
+ receivedMessageReset.Set();
+ }
+ };
+
+ var sendResult = await channel.SendText("FILE TOO BIG", new SendTextParams()
+ {
+ Files =
+ [
+ new ChatInputFile()
+ {
+ Name = LARGE_FILE_NAME,
+ Type = "image",
+ Source = LARGE_FILE_LOCATION
+ }
+ ]
+ });
+ Assert.True(sendResult.Error, "sendResult.Error should be true for file over size limit");
+ Assert.True(sendResult.Exception.Message.Contains("Your proposed upload exceeds the maximum allowed size"), "Error message should contain info about file size");
+
+ var received = receivedMessageReset.WaitOne(5000);
+ Assert.False(received, "SendText should abort and not send message in case of file upload error");
+ }
+}
\ No newline at end of file
diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/PubNubChatApi.Tests.csproj b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/PubNubChatApi.Tests.csproj
index 4da4cae..5389c8f 100644
--- a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/PubNubChatApi.Tests.csproj
+++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/PubNubChatApi.Tests.csproj
@@ -25,4 +25,13 @@
+
+
+ Always
+
+
+ Always
+
+
+
diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/PushTests.cs b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/PushTests.cs
new file mode 100644
index 0000000..dd38c9a
--- /dev/null
+++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/PushTests.cs
@@ -0,0 +1,80 @@
+using PubnubApi;
+using PubnubChatApi;
+using Channel = PubnubChatApi.Channel;
+
+namespace PubNubChatApi.Tests;
+
+[TestFixture]
+public class PushTests
+{
+ private Chat chat;
+ private Channel channel;
+ private User user;
+
+ [SetUp]
+ public async Task Setup()
+ {
+ chat = TestUtils.AssertOperation(await Chat.CreateInstance(
+ new PubnubChatConfig(pushNotifications: new PubnubChatConfig.PushNotificationsConfig()
+ {
+ APNSEnvironment = PushEnvironment.Development,
+ APNSTopic = "someTopic",
+ DeviceGateway = PNPushType.FCM,
+ DeviceToken = "sometoken",
+ SendPushes = true
+ }),
+ new PNConfiguration(new UserId("push_tests_user"))
+ {
+ PublishKey = PubnubTestsParameters.PublishKey,
+ SubscribeKey = PubnubTestsParameters.SubscribeKey,
+ }));
+ channel = TestUtils.AssertOperation(await chat.CreatePublicConversation("push_tests_channel",
+ new ChatChannelData() { Name = "Push Channels Name" }));
+ user = TestUtils.AssertOperation(await chat.GetCurrentUser());
+ await channel.Join();
+ await Task.Delay(3500);
+ }
+
+ [TearDown]
+ public async Task CleanUp()
+ {
+ await channel.Leave();
+ await Task.Delay(3000);
+ chat.Destroy();
+ await Task.Delay(3000);
+ }
+
+ [Test]
+ public async Task TestAddAndRemovePushChannel()
+ {
+ var res = await channel.RegisterForPush();
+ TestUtils.AssertOperation(res);
+ await Task.Delay(2000);
+ var pushChannels = TestUtils.AssertOperation(await chat.GetPushChannels());
+ Assert.True(pushChannels.Contains(channel.Id), "Push channels don't contain registered channel ID");
+ TestUtils.AssertOperation(await channel.UnRegisterFromPush());
+ await Task.Delay(2000);
+ pushChannels = TestUtils.AssertOperation(await chat.GetPushChannels());
+ Assert.False(pushChannels.Contains(channel.Id), "Push channels contain unregistered channel ID");
+ }
+
+ [Test]
+ public async Task TestRemoveAllPushChannel()
+ {
+ TestUtils.AssertOperation(await channel.RegisterForPush());
+ await Task.Delay(2000);
+ var pushChannels = TestUtils.AssertOperation(await chat.GetPushChannels());
+ Assert.True(pushChannels.Contains(channel.Id), "Push channels don't contain registered channel ID");
+ TestUtils.AssertOperation(await chat.UnRegisterAllPushChannels());
+ pushChannels = TestUtils.AssertOperation(await chat.GetPushChannels());
+ Assert.False(pushChannels.Contains(channel.Id), "Push channels contain unregistered channel ID");
+ }
+
+ [Test]
+ public async Task TestPublishWithPushData()
+ {
+ TestUtils.AssertOperation(await channel.SendText("some_message",
+ new SendTextParams()
+ { CustomPushData = new Dictionary() { { "some_key", "some_value" } } }));
+ }
+}
\ No newline at end of file
diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/file_large.png b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/file_large.png
new file mode 100644
index 0000000..9dd4414
Binary files /dev/null and b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/file_large.png differ
diff --git a/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/fileupload.txt b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/fileupload.txt
new file mode 100644
index 0000000..61a4a0b
--- /dev/null
+++ b/c-sharp-chat/PubnubChatApi/PubNubChatApi.Tests/fileupload.txt
@@ -0,0 +1 @@
+This is the file to be uploaded for testing.
\ 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 966370e..0fd42b5 100644
--- a/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Channel.cs
+++ b/c-sharp-chat/PubnubChatApi/PubnubChatApi/Entities/Channel.cs
@@ -4,6 +4,7 @@
using System.Threading.Tasks;
using System.Timers;
using PubnubApi;
+using Environment = PubnubApi.Environment;
namespace PubnubChatApi
{
@@ -800,6 +801,32 @@ public async Task SendText(string message)
return await SendText(message, new SendTextParams()).ConfigureAwait(false);
}
+ private async Task> SendFileForPublish(ChatInputFile inputFile)
+ {
+ var result = new ChatOperationResult("Channel.SendFileForPublish()", chat);
+ var send = await chat.PubnubInstance.SendFile().Channel(Id).File(inputFile.Source).FileName(inputFile.Name)
+ .ShouldStore(false)
+ .ExecuteAsync().ConfigureAwait(false);
+ if (result.RegisterOperation(send))
+ {
+ return result;
+ }
+ var getUrl = await chat.PubnubInstance.GetFileUrl().Channel(Id).FileId(send.Result.FileId)
+ .FileName(send.Result.FileName).ExecuteAsync().ConfigureAwait(false);
+ if (result.RegisterOperation(getUrl))
+ {
+ return result;
+ }
+ result.Result = new ChatFile()
+ {
+ Id = send.Result.FileId,
+ Name = send.Result.FileName,
+ Type = inputFile.Type,
+ Url = getUrl.Result.Url
+ };
+ return result;
+ }
+
///
/// Sends the text message with additional parameters.
///
@@ -812,6 +839,7 @@ public async Task SendText(string message)
public virtual async Task SendText(string message, SendTextParams sendTextParams)
{
var result = new ChatOperationResult("Channel.SendText()", chat);
+ var jsonLibrary = chat.PubnubInstance.JsonPluggableLibrary;
var baseInterval = Type switch
{
@@ -829,6 +857,35 @@ public virtual async Task SendText(string message, SendText
{"text", message},
{"type", "text"}
};
+ if (chat.Config.PushNotifications is { SendPushes : true})
+ {
+ var pushPayload = await GetPushPayload(message, sendTextParams.CustomPushData ?? new Dictionary());
+ foreach (var kvp in pushPayload)
+ {
+ var pushJson = jsonLibrary.SerializeToJsonString(kvp.Value);
+ messageDict[kvp.Key] = pushJson;
+ }
+ }
+ if (sendTextParams.Files.Any())
+ {
+ var fileTasks = sendTextParams.Files.Select(SendFileForPublish);
+ var fileResults = await Task.WhenAll(fileTasks).ConfigureAwait(false);
+ foreach (var fileResult in fileResults)
+ {
+ result.RegisterOperation(fileResult);
+ }
+ var failedUploads = fileResults.Where(x => x.Error).ToList();
+ if (failedUploads.Any())
+ {
+ var combinedException = string.Join("\n",failedUploads.Select(x => x.Exception.Message));
+ result.Exception = new PNException($"Message publishing aborted: {failedUploads.Count} out of {fileResults.Length} " +
+ $"file uploads failed. Exceptions from file uploads: {combinedException}");
+ result.Error = true;
+ return result;
+ }
+ var chatFilesAsDictionaries = fileResults.Select(x => x.Result.ToDictionary()).ToList();
+ messageDict["files"] = jsonLibrary.SerializeToJsonString(chatFilesAsDictionaries);
+ }
var meta = sendTextParams.Meta ?? new Dictionary();
if (sendTextParams.QuotedMessage != null)
{
@@ -851,7 +908,7 @@ public virtual async Task SendText(string message, SendText
.Channel(Id)
.ShouldStore(sendTextParams.StoreInHistory)
.UsePOST(sendTextParams.SendByPost)
- .Message(chat.PubnubInstance.JsonPluggableLibrary.SerializeToJsonString(messageDict))
+ .Message(jsonLibrary.SerializeToJsonString(messageDict))
.Meta(meta)
.ExecuteAsync().ConfigureAwait(false);
if (result.RegisterOperation(publishResult))
@@ -874,6 +931,8 @@ public virtual async Task SendText(string message, SendText
}, exception =>
{
chat.Logger.Error($"Error occured when trying to SendText(): {exception.Message}");
+ result.Error = true;
+ result.Exception = new PNException($"Encountered exception in SendText(): {exception.Message}");
completionSource.SetResult(true);
});
@@ -1108,6 +1167,8 @@ public async Task> IsUserPresent(string userId)
/// Gets all the users that are present in the channel.
///
///
+ /// Limit number of users details to be returned. Default and max value is 1000.
+ /// Use this parameter to provide starting position of results for pagination purpose. Default value is 0.
/// A ChatOperationResult containing the list of users present in the channel.
///
///
@@ -1120,11 +1181,15 @@ public async Task> IsUserPresent(string userId)
///
///
///
- public async Task>> WhoIsPresent()
+ public async Task>> WhoIsPresent(int limit = 1000, int offset = 0)
{
+ if (limit > 1000)
+ {
+ limit = 1000;
+ }
var result = new ChatOperationResult>("Channel.WhoIsPresent()", chat) { Result = new List() };
var response = await chat.PubnubInstance.HereNow().Channels(new[] { Id }).IncludeState(true)
- .IncludeUUIDs(true).ExecuteAsync().ConfigureAwait(false);
+ .IncludeUUIDs(true).Limit(limit).Offset(offset).ExecuteAsync().ConfigureAwait(false);
if (result.RegisterOperation(response))
{
return result;
@@ -1242,5 +1307,136 @@ public async Task>> InviteMultiple(List