From 415c62cf7c41fb54f8e47665bff6531a91c3ef95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 20 May 2026 13:53:03 +0200 Subject: [PATCH 01/19] Implement StreamChatClient.SearchMessagesAsync --- .../StreamChat/Core/IStreamChatClient.cs | 25 +++ .../Filters/BaseFieldToFilter.cs | 3 + .../Core/QueryBuilders/Filters/Messages.meta | 8 + .../Messages/MessageFieldAttachmentType.cs | 21 ++ .../MessageFieldAttachmentType.cs.meta | 11 ++ .../Filters/Messages/MessageFieldCreatedAt.cs | 28 +++ .../Messages/MessageFieldCreatedAt.cs.meta | 11 ++ .../Filters/Messages/MessageFieldCustom.cs | 53 +++++ .../Messages/MessageFieldCustom.cs.meta | 11 ++ .../Messages/MessageFieldMentionedUserId.cs | 31 +++ .../MessageFieldMentionedUserId.cs.meta | 11 ++ .../Filters/Messages/MessageFieldParentId.cs | 34 ++++ .../Messages/MessageFieldParentId.cs.meta | 11 ++ .../Filters/Messages/MessageFieldPinned.cs | 15 ++ .../Messages/MessageFieldPinned.cs.meta | 11 ++ .../Filters/Messages/MessageFieldPollId.cs | 27 +++ .../Messages/MessageFieldPollId.cs.meta | 11 ++ .../Messages/MessageFieldReactionType.cs | 21 ++ .../Messages/MessageFieldReactionType.cs.meta | 11 ++ .../Messages/MessageFieldShowInChannel.cs | 15 ++ .../MessageFieldShowInChannel.cs.meta | 11 ++ .../Filters/Messages/MessageFieldSilent.cs | 14 ++ .../Messages/MessageFieldSilent.cs.meta | 11 ++ .../Filters/Messages/MessageFieldText.cs | 31 +++ .../Filters/Messages/MessageFieldText.cs.meta | 11 ++ .../MessageFieldThreadParticipantId.cs | 30 +++ .../MessageFieldThreadParticipantId.cs.meta | 11 ++ .../Filters/Messages/MessageFieldType.cs | 20 ++ .../Filters/Messages/MessageFieldType.cs.meta | 11 ++ .../Filters/Messages/MessageFieldUpdatedAt.cs | 28 +++ .../Messages/MessageFieldUpdatedAt.cs.meta | 11 ++ .../Filters/Messages/MessageFieldUserId.cs | 26 +++ .../Messages/MessageFieldUserId.cs.meta | 11 ++ .../Filters/Messages/MessageFilter.cs | 60 ++++++ .../Filters/Messages/MessageFilter.cs.meta | 11 ++ .../Core/QueryBuilders/Sort/MessagesSort.cs | 44 +++++ .../QueryBuilders/Sort/MessagesSort.cs.meta | 11 ++ .../QueryBuilders/Sort/MessagesSortObject.cs | 36 ++++ .../Sort/MessagesSortObject.cs.meta | 11 ++ .../Requests/StreamSearchMessagesRequest.cs | 104 ++++++++++ .../StreamSearchMessagesRequest.cs.meta | 11 ++ .../Responses/StreamSearchMessageResult.cs | 31 +++ .../StreamSearchMessageResult.cs.meta | 11 ++ .../Responses/StreamSearchMessagesResponse.cs | 37 ++++ .../StreamSearchMessagesResponse.cs.meta | 11 ++ .../Core/Responses/StreamSearchWarning.cs | 31 +++ .../Responses/StreamSearchWarning.cs.meta | 11 ++ .../StreamChat/Core/StreamChatClient.cs | 184 ++++++++++++++++++ 48 files changed, 1199 insertions(+) create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs create mode 100644 Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta diff --git a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs index 8ffd826c..1f4a61a9 100644 --- a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs @@ -270,6 +270,31 @@ Task GetThreadAsync(string parentMessageId, /// Query request Task QueryThreadsAsync(StreamQueryThreadsRequest request); + /// + /// Search messages across the channels the local user can access. + /// + /// Unlike the low-level Client.LowLevelClient.MessageApi.SearchMessagesAsync, results + /// are returned as cached, stateful (and accompanying + /// ) instances - the same objects already in the cache are + /// reused, and they continue to react to realtime WebSocket events. + /// + /// + /// The requires a channel-level filter (e.g. + /// ChannelFilter.Members.In(localUser)). Additional message-level filters can be + /// expressed with MessageFilter.* builders, and a free-text phrase can be supplied + /// via . See + /// for pagination and sorting options. + /// + /// + /// Search parameters - channel filter, message filter, query phrase, + /// sort, and pagination. + /// [Optional] Cancellation token for the request. + /// Stateful results plus pagination cursors. + /// https://getstream.io/chat/docs/unity/search/?language=unity + Task SearchMessagesAsync( + StreamSearchMessagesRequest request, + CancellationToken cancellationToken = default(CancellationToken)); + /// /// Upsert users. Upsert means update this user or create if not found /// diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs index 5889db0d..e54548a8 100644 --- a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs @@ -87,5 +87,8 @@ protected FieldFilterRule InternalAutocomplete(string value) protected FieldFilterRule InternalContains(string value) => new FieldFilterRule(FieldName, QueryOperatorType.Contains, value); + + protected FieldFilterRule InternalExists(bool exists) + => new FieldFilterRule(FieldName, QueryOperatorType.Exists, exists); } } \ No newline at end of file diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta new file mode 100644 index 00000000..53c28cdb --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 12a1ae9203376aa4d90b214896d6c176 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs new file mode 100644 index 00000000..746a6dfc --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the type of an attachment on the message (image, video, + /// file, audio, giphy, location, or any custom type). + /// + public sealed class MessageFieldAttachmentType : BaseFieldToFilter + { + public override string FieldName => "attachments.type"; + + public FieldFilterRule EqualsTo(string attachmentType) => InternalEqualsTo(attachmentType); + + public FieldFilterRule Contains(string attachmentType) => InternalContains(attachmentType); + + public FieldFilterRule In(IEnumerable attachmentTypes) => InternalIn(attachmentTypes); + + public FieldFilterRule In(params string[] attachmentTypes) => InternalIn(attachmentTypes); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta new file mode 100644 index 00000000..10ee944a --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ad3e2c90c46e0f84db35b098e3ab06ed +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs new file mode 100644 index 00000000..9dbdf854 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs @@ -0,0 +1,28 @@ +using System; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message timestamp. + /// + public sealed class MessageFieldCreatedAt : BaseFieldToFilter + { + public override string FieldName => "created_at"; + + public FieldFilterRule EqualsTo(DateTime date) => InternalEqualsTo(date); + public FieldFilterRule EqualsTo(DateTimeOffset date) => InternalEqualsTo(date); + + public FieldFilterRule GreaterThan(DateTime date) => InternalGreaterThan(date); + public FieldFilterRule GreaterThan(DateTimeOffset date) => InternalGreaterThan(date); + + public FieldFilterRule GreaterThanOrEquals(DateTime date) => InternalGreaterThanOrEquals(date); + public FieldFilterRule GreaterThanOrEquals(DateTimeOffset date) => InternalGreaterThanOrEquals(date); + + public FieldFilterRule LessThan(DateTime date) => InternalLessThan(date); + public FieldFilterRule LessThan(DateTimeOffset date) => InternalLessThan(date); + + public FieldFilterRule LessThanOrEquals(DateTime date) => InternalLessThanOrEquals(date); + public FieldFilterRule LessThanOrEquals(DateTimeOffset date) => InternalLessThanOrEquals(date); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta new file mode 100644 index 00000000..ed3776a2 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f71526cd6b0a1e1429d9d6c435c5f05b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs new file mode 100644 index 00000000..8390b8dd --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using StreamChat.Core.State; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by an arbitrary custom message field (any top-level key the customer attached to the message). + /// + public sealed class MessageFieldCustom : BaseFieldToFilter + { + public override string FieldName { get; } + + public MessageFieldCustom(string customFieldName) + { + StreamAsserts.AssertNotNullOrEmpty(customFieldName, nameof(customFieldName)); + FieldName = customFieldName; + } + + public FieldFilterRule EqualsTo(string value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(bool value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(int value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(DateTime value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(DateTimeOffset value) => InternalEqualsTo(value); + + public FieldFilterRule In(IEnumerable values) => InternalIn(values); + public FieldFilterRule In(params string[] values) => InternalIn(values); + + public FieldFilterRule GreaterThan(int value) => InternalGreaterThan(value); + public FieldFilterRule GreaterThan(string value) => InternalGreaterThan(value); + public FieldFilterRule GreaterThan(DateTime value) => InternalGreaterThan(value); + public FieldFilterRule GreaterThan(DateTimeOffset value) => InternalGreaterThan(value); + + public FieldFilterRule GreaterThanOrEquals(int value) => InternalGreaterThanOrEquals(value); + public FieldFilterRule GreaterThanOrEquals(string value) => InternalGreaterThanOrEquals(value); + public FieldFilterRule GreaterThanOrEquals(DateTime value) => InternalGreaterThanOrEquals(value); + public FieldFilterRule GreaterThanOrEquals(DateTimeOffset value) => InternalGreaterThanOrEquals(value); + + public FieldFilterRule LessThan(int value) => InternalLessThan(value); + public FieldFilterRule LessThan(string value) => InternalLessThan(value); + public FieldFilterRule LessThan(DateTime value) => InternalLessThan(value); + public FieldFilterRule LessThan(DateTimeOffset value) => InternalLessThan(value); + + public FieldFilterRule LessThanOrEquals(int value) => InternalLessThanOrEquals(value); + public FieldFilterRule LessThanOrEquals(string value) => InternalLessThanOrEquals(value); + public FieldFilterRule LessThanOrEquals(DateTime value) => InternalLessThanOrEquals(value); + public FieldFilterRule LessThanOrEquals(DateTimeOffset value) => InternalLessThanOrEquals(value); + + public FieldFilterRule Contains(string value) => InternalContains(value); + + public FieldFilterRule Exists(bool exists) => InternalExists(exists); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta new file mode 100644 index 00000000..f3666ef9 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1f624ee34cf12841bf7c795fcb4c638 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs new file mode 100644 index 00000000..487d1d1c --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the id of a user mentioned in the message + /// (). + /// + public sealed class MessageFieldMentionedUserId : BaseFieldToFilter + { + public override string FieldName => "mentioned_users.id"; + + public FieldFilterRule EqualsTo(string userId) => InternalEqualsTo(userId); + + public FieldFilterRule EqualsTo(IStreamUser user) => InternalEqualsTo(user.Id); + + public FieldFilterRule Contains(string userId) => InternalContains(userId); + + public FieldFilterRule Contains(IStreamUser user) => InternalContains(user.Id); + + public FieldFilterRule In(IEnumerable userIds) => InternalIn(userIds); + + public FieldFilterRule In(params string[] userIds) => InternalIn(userIds); + + public FieldFilterRule In(IEnumerable users) => InternalIn(users.Select(_ => _.Id)); + + public FieldFilterRule In(params IStreamUser[] users) => InternalIn(users.Select(_ => _.Id)); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta new file mode 100644 index 00000000..eb6ef424 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 92cefb1a02b05d54aa8167aaf8fe9465 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs new file mode 100644 index 00000000..bcdad229 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// + /// Typical usage: + /// + /// Exists(true) - only thread replies. + /// Exists(false) - only top-level messages. + /// EqualsTo(parentId) - replies to a specific parent message. + /// + /// + public sealed class MessageFieldParentId : BaseFieldToFilter + { + public override string FieldName => "parent_id"; + + public FieldFilterRule EqualsTo(string parentMessageId) => InternalEqualsTo(parentMessageId); + + public FieldFilterRule EqualsTo(IStreamMessage parentMessage) => InternalEqualsTo(parentMessage.Id); + + public FieldFilterRule In(IEnumerable parentMessageIds) => InternalIn(parentMessageIds); + + public FieldFilterRule In(params string[] parentMessageIds) => InternalIn(parentMessageIds); + + /// + /// When true, returns only replies (messages whose parent_id is set). + /// When false, returns only top-level (non-reply) messages. + /// + public FieldFilterRule Exists(bool exists) => InternalExists(exists); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta new file mode 100644 index 00000000..cf00dbec --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8453c21d05b7d354e9b339965ab6eb25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs new file mode 100644 index 00000000..9dd34a6e --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs @@ -0,0 +1,15 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// Useful for cross-channel pinned-message searches. + /// + public sealed class MessageFieldPinned : BaseFieldToFilter + { + public override string FieldName => "pinned"; + + public FieldFilterRule EqualsTo(bool pinned) => InternalEqualsTo(pinned); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta new file mode 100644 index 00000000..4ab86db5 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25b7f4db1fcc6f840a3579877f4545f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs new file mode 100644 index 00000000..5271c757 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the id of a poll attached to the message. + /// + /// Use with true to find any message that has a poll attached, + /// or pass a specific poll id to find the message that hosts a known poll. + /// + public sealed class MessageFieldPollId : BaseFieldToFilter + { + public override string FieldName => "poll_id"; + + public FieldFilterRule EqualsTo(string pollId) => InternalEqualsTo(pollId); + + public FieldFilterRule In(IEnumerable pollIds) => InternalIn(pollIds); + + public FieldFilterRule In(params string[] pollIds) => InternalIn(pollIds); + + /// + /// When true, returns only messages that have a poll attached. + /// When false, returns only messages without a poll. + /// + public FieldFilterRule Exists(bool exists) => InternalExists(exists); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta new file mode 100644 index 00000000..7d3eed72 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0adffc8d461563d4c8caf779058f1155 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs new file mode 100644 index 00000000..d0dbdf11 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the type of a reaction on the message (e.g. like, love, fire). + /// Matches the latest reactions tracked server-side. + /// + public sealed class MessageFieldReactionType : BaseFieldToFilter + { + public override string FieldName => "latest_reactions.type"; + + public FieldFilterRule EqualsTo(string reactionType) => InternalEqualsTo(reactionType); + + public FieldFilterRule Contains(string reactionType) => InternalContains(reactionType); + + public FieldFilterRule In(IEnumerable reactionTypes) => InternalIn(reactionTypes); + + public FieldFilterRule In(params string[] reactionTypes) => InternalIn(reactionTypes); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta new file mode 100644 index 00000000..82f89055 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a44b919dcbbc6d48a35c098cc8f0bbd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs new file mode 100644 index 00000000..ff499037 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs @@ -0,0 +1,15 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . Relevant for thread replies that are also + /// shown in the parent channel feed. + /// + public sealed class MessageFieldShowInChannel : BaseFieldToFilter + { + public override string FieldName => "show_in_channel"; + + public FieldFilterRule EqualsTo(bool showInChannel) => InternalEqualsTo(showInChannel); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta new file mode 100644 index 00000000..3ffadb98 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 163b3770081860f4d9b081e39326e111 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs new file mode 100644 index 00000000..4452200b --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs @@ -0,0 +1,14 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// + public sealed class MessageFieldSilent : BaseFieldToFilter + { + public override string FieldName => "silent"; + + public FieldFilterRule EqualsTo(bool silent) => InternalEqualsTo(silent); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta new file mode 100644 index 00000000..4673accd --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d909a6342303b5d43a2353c16511aa28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs new file mode 100644 index 00000000..f2b2b2cd --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs @@ -0,0 +1,31 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// + /// Note: combining a text rule with + /// is rejected by the server + /// and validated client-side. + /// + public sealed class MessageFieldText : BaseFieldToFilter + { + public override string FieldName => "text"; + + /// + /// Return only messages where is EQUAL to the provided value. + /// + public FieldFilterRule EqualsTo(string text) => InternalEqualsTo(text); + + /// + /// Return only messages where CONTAINS the provided phrase. + /// + public FieldFilterRule Contains(string phrase) => InternalContains(phrase); + + /// + /// Return only messages where matches the provided autocomplete phrase. + /// + public FieldFilterRule Autocomplete(string phrase) => InternalAutocomplete(phrase); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta new file mode 100644 index 00000000..374abaae --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0a31cadb092db540b80b057127fca14 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs new file mode 100644 index 00000000..461fb7c3 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the id of a user participating in the thread the message belongs to. + /// + public sealed class MessageFieldThreadParticipantId : BaseFieldToFilter + { + public override string FieldName => "thread_participants.id"; + + public FieldFilterRule EqualsTo(string userId) => InternalEqualsTo(userId); + + public FieldFilterRule EqualsTo(IStreamUser user) => InternalEqualsTo(user.Id); + + public FieldFilterRule Contains(string userId) => InternalContains(userId); + + public FieldFilterRule Contains(IStreamUser user) => InternalContains(user.Id); + + public FieldFilterRule In(IEnumerable userIds) => InternalIn(userIds); + + public FieldFilterRule In(params string[] userIds) => InternalIn(userIds); + + public FieldFilterRule In(IEnumerable users) => InternalIn(users.Select(_ => _.Id)); + + public FieldFilterRule In(params IStreamUser[] users) => InternalIn(users.Select(_ => _.Id)); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta new file mode 100644 index 00000000..01d64324 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54a3ff0de0069584db1bffd9e03b619d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs new file mode 100644 index 00000000..60fc55bf --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message type (). + /// Common values: regular, system, deleted, reply, ephemeral. + /// + public sealed class MessageFieldType : BaseFieldToFilter + { + public override string FieldName => "type"; + + public FieldFilterRule EqualsTo(string messageType) => InternalEqualsTo(messageType); + + public FieldFilterRule In(IEnumerable messageTypes) => InternalIn(messageTypes); + + public FieldFilterRule In(params string[] messageTypes) => InternalIn(messageTypes); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta new file mode 100644 index 00000000..5138f056 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d67fc6842b6968348be027a872d6fdce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs new file mode 100644 index 00000000..73daac48 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs @@ -0,0 +1,28 @@ +using System; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message timestamp. + /// + public sealed class MessageFieldUpdatedAt : BaseFieldToFilter + { + public override string FieldName => "updated_at"; + + public FieldFilterRule EqualsTo(DateTime date) => InternalEqualsTo(date); + public FieldFilterRule EqualsTo(DateTimeOffset date) => InternalEqualsTo(date); + + public FieldFilterRule GreaterThan(DateTime date) => InternalGreaterThan(date); + public FieldFilterRule GreaterThan(DateTimeOffset date) => InternalGreaterThan(date); + + public FieldFilterRule GreaterThanOrEquals(DateTime date) => InternalGreaterThanOrEquals(date); + public FieldFilterRule GreaterThanOrEquals(DateTimeOffset date) => InternalGreaterThanOrEquals(date); + + public FieldFilterRule LessThan(DateTime date) => InternalLessThan(date); + public FieldFilterRule LessThan(DateTimeOffset date) => InternalLessThan(date); + + public FieldFilterRule LessThanOrEquals(DateTime date) => InternalLessThanOrEquals(date); + public FieldFilterRule LessThanOrEquals(DateTimeOffset date) => InternalLessThanOrEquals(date); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta new file mode 100644 index 00000000..f9c76828 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 94b8bba30f6d61e42918e400e848c460 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs new file mode 100644 index 00000000..4be3892d --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message author's user id (.). + /// + public sealed class MessageFieldUserId : BaseFieldToFilter + { + public override string FieldName => "user.id"; + + public FieldFilterRule EqualsTo(string userId) => InternalEqualsTo(userId); + + public FieldFilterRule EqualsTo(IStreamUser user) => InternalEqualsTo(user.Id); + + public FieldFilterRule In(IEnumerable userIds) => InternalIn(userIds); + + public FieldFilterRule In(params string[] userIds) => InternalIn(userIds); + + public FieldFilterRule In(IEnumerable users) => InternalIn(users.Select(_ => _.Id)); + + public FieldFilterRule In(params IStreamUser[] users) => InternalIn(users.Select(_ => _.Id)); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta new file mode 100644 index 00000000..6d60fa0b --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5dde1fb4540acab4b933f1a3f5807638 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs new file mode 100644 index 00000000..cb4396d1 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs @@ -0,0 +1,60 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filters for conditions used by + /// . + /// + /// These rules go into + /// and are applied to messages within the channels matched by + /// . + /// + public static class MessageFilter + { + /// + public static MessageFieldText Text { get; } = new MessageFieldText(); + + /// + public static MessageFieldUserId UserId { get; } = new MessageFieldUserId(); + + /// + public static MessageFieldType Type { get; } = new MessageFieldType(); + + /// + public static MessageFieldCreatedAt CreatedAt { get; } = new MessageFieldCreatedAt(); + + /// + public static MessageFieldUpdatedAt UpdatedAt { get; } = new MessageFieldUpdatedAt(); + + /// + public static MessageFieldParentId ParentId { get; } = new MessageFieldParentId(); + + /// + public static MessageFieldPinned Pinned { get; } = new MessageFieldPinned(); + + /// + public static MessageFieldSilent Silent { get; } = new MessageFieldSilent(); + + /// + public static MessageFieldMentionedUserId MentionedUserId { get; } = new MessageFieldMentionedUserId(); + + /// + public static MessageFieldThreadParticipantId ThreadParticipantId { get; } = new MessageFieldThreadParticipantId(); + + /// + public static MessageFieldAttachmentType AttachmentType { get; } = new MessageFieldAttachmentType(); + + /// + public static MessageFieldReactionType ReactionType { get; } = new MessageFieldReactionType(); + + /// + public static MessageFieldPollId PollId { get; } = new MessageFieldPollId(); + + /// + public static MessageFieldShowInChannel ShowInChannel { get; } = new MessageFieldShowInChannel(); + + /// + public static MessageFieldCustom Custom(string customFieldName) => new MessageFieldCustom(customFieldName); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta new file mode 100644 index 00000000..7b1384d4 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d8f63c8488b32c459aff48d37330da3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs new file mode 100644 index 00000000..856d25a2 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs @@ -0,0 +1,44 @@ +namespace StreamChat.Core.QueryBuilders.Sort +{ + /// + /// Factory for sort object building. + /// + /// + /// Note: the server forbids combining a sort with a non-zero offset. To paginate + /// sorted results use the Next cursor returned by the previous response. + /// + public static class MessagesSort + { + /// + /// Sort in ascending order (lowest to highest) by the specified field. + /// + public static MessagesSortObject OrderByAscending(MessageSortFieldName fieldName) + { + var instance = new MessagesSortObject(); + instance.OrderByAscending(fieldName); + return instance; + } + + /// + /// Sort in descending order (highest to lowest) by the specified field. + /// + public static MessagesSortObject OrderByDescending(MessageSortFieldName fieldName) + { + var instance = new MessagesSortObject(); + instance.OrderByDescending(fieldName); + return instance; + } + + /// + /// Then sort in ascending order (lowest to highest) by the specified field. + /// + public static MessagesSortObject ThenByAscending(this MessagesSortObject sort, MessageSortFieldName fieldName) + => sort.OrderByAscending(fieldName); + + /// + /// Then sort in descending order (highest to lowest) by the specified field. + /// + public static MessagesSortObject ThenByDescending(this MessagesSortObject sort, MessageSortFieldName fieldName) + => sort.OrderByDescending(fieldName); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta new file mode 100644 index 00000000..37bbb3b9 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a6573e3abec6fe4aa6592e539dc7a40 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs new file mode 100644 index 00000000..4ed76349 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs @@ -0,0 +1,36 @@ +using System; + +namespace StreamChat.Core.QueryBuilders.Sort +{ + /// + /// Sort object for . + /// + public sealed class MessagesSortObject : QuerySort + { + protected override MessagesSortObject Instance => this; + + protected override string ToUnderlyingFieldName(MessageSortFieldName fieldName) + { + switch (fieldName) + { + case MessageSortFieldName.CreatedAt: return "created_at"; + case MessageSortFieldName.UpdatedAt: return "updated_at"; + case MessageSortFieldName.Relevance: return "relevance"; + case MessageSortFieldName.Id: return "id"; + default: + throw new ArgumentOutOfRangeException(nameof(fieldName), fieldName, null); + } + } + } + + /// + /// Sort field names for . + /// + public enum MessageSortFieldName + { + CreatedAt, + UpdatedAt, + Relevance, + Id, + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta new file mode 100644 index 00000000..caa853b0 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3384c2c836c2424ab7d941057bb28fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs new file mode 100644 index 00000000..74f12a21 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.InternalDTO.Requests; +using StreamChat.Core.LowLevelClient; +using StreamChat.Core.QueryBuilders.Filters; +using StreamChat.Core.QueryBuilders.Sort; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.Requests +{ + /// + /// Request for . + /// + /// + /// is required by the server - at least one channel-level rule + /// must be provided (e.g. ChannelFilter.Members.In(localUser)) to scope the search. + /// + /// + public sealed class StreamSearchMessagesRequest : ISavableTo + { + /// + /// REQUIRED. Filter restricting which channels are searched. Use + /// to build the rules. + /// + /// Typical: ChannelFilter.Members.In(Client.LocalUserData.User). + /// + public IEnumerable ChannelFilter { get; set; } + + /// + /// Optional. Filter restricting which messages within the matched channels match. Use + /// to build the rules. + /// + /// Mutually exclusive at the text field with ; that combination + /// is rejected client-side before the request is sent. + /// + public IEnumerable MessageFilter { get; set; } + + /// + /// Optional. Free-text search phrase. Performs full-text search on the message text. + /// + /// Cannot be combined with a rule targeting the text field. + /// + public string Query { get; set; } + + /// + /// Optional. Max number of results per page. The server default and recommended max for + /// offset-based pagination is 30. + /// + public int? Limit { get; set; } + + /// + /// Optional. Offset-based pagination. Capped at 1000 total results by the server. + /// + /// Mutually exclusive with and with when greater than zero. + /// + public int? Offset { get; set; } + + /// + /// Optional. Cursor for the next page - pass the + /// value from a previous response. + /// + /// Mutually exclusive with . + /// + public string Next { get; set; } + + /// + /// Optional. Sort criteria. The server forbids combining a sort with a non-zero + /// ; use for sorted pagination. + /// + public MessagesSortObject Sort { get; set; } + + /// + /// Whether the SDK should start watching the channels that appear in the result set so + /// that the returned instances and their parent + /// receive realtime WebSocket updates. + /// + /// Default: false. Recommended for a search-results UI - watch the channel only when + /// the user opens one of the hits to avoid mass-watching channels behind the customer's back. + /// When false, hit messages are still cached as , but their + /// parent only receives realtime events once explicitly watched + /// (e.g. via or + /// ). + /// + public bool WatchResultChannels { get; set; } + + SearchRequestInternalDTO ISavableTo.SaveToDto() + { + return new SearchRequestInternalDTO + { + FilterConditions = ChannelFilter? + .Select(_ => _.GenerateFilterEntry()) + .ToDictionary(x => x.Key, x => x.Value), + MessageFilterConditions = MessageFilter? + .Select(_ => _.GenerateFilterEntry()) + .ToDictionary(x => x.Key, x => x.Value), + Query = Query, + Limit = Limit, + Offset = Offset, + Next = Next, + Sort = Sort?.ToSortParamRequestList(), + }; + } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta new file mode 100644 index 00000000..74216a12 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 584252d3533a2564fbbee33f2e0ad8cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs new file mode 100644 index 00000000..69de3187 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs @@ -0,0 +1,31 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.Responses +{ + /// + /// A single hit from . + /// + /// Both and are stateful, cache-tracked instances: + /// if the same message/channel is already in the cache (because the channel is watched, the + /// message was loaded as a reply, etc.) the same object reference is returned here. + /// + public sealed class StreamSearchMessageResult + { + /// + /// The matching message. Updated by realtime events the same way as any other + /// stateful message returned by the SDK. + /// + public IStreamMessage Message { get; internal set; } + + /// + /// The channel the message belongs to. May be the same instance as one in + /// if the channel is already watched. + /// + /// + /// The channel object is cached but is not automatically watched (no WS subscription) + /// unless is set + /// to true. + /// + public IStreamChannel Channel { get; internal set; } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta new file mode 100644 index 00000000..2105649d --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7490a677bccbd0249b513b6d10901f5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs new file mode 100644 index 00000000..57c8ccf4 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.Responses +{ + /// + /// Response from . + /// + public sealed class StreamSearchMessagesResponse + { + /// + /// Stateful, cached message hits in server-defined order. + /// + public IReadOnlyList Results { get; internal set; } + + /// + /// Cursor for the next page; null if there are no more pages. + /// Pass this value as to retrieve the next page. + /// + public string Next { get; internal set; } + + /// + /// Cursor for the previous page; null on the first page. + /// + public string Previous { get; internal set; } + + /// + /// Human-readable request duration as reported by the server. + /// + public string Duration { get; internal set; } + + /// + /// Optional warning emitted by the server about the search result set + /// (e.g. truncated channel scope). + /// + public StreamSearchWarning ResultsWarning { get; internal set; } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta new file mode 100644 index 00000000..938591ae --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77e52e143dd57e040a7a2a83e1c4b3cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs new file mode 100644 index 00000000..0550375c --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.Responses +{ + /// + /// Warning emitted by the server alongside a + /// response (e.g. when the searched-channel scope was truncated). + /// + public sealed class StreamSearchWarning + { + /// + /// Numeric warning code as reported by the server, or null if not provided. + /// + public int? Code { get; internal set; } + + /// + /// Human-readable description of the warning. + /// + public string Description { get; internal set; } + + /// + /// Number of channels included in the searched scope, when reported by the server. + /// + public int? ChannelSearchCount { get; internal set; } + + /// + /// Cids of the channels that were searched, when reported by the server. + /// + public IReadOnlyList ChannelIds { get; internal set; } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta new file mode 100644 index 00000000..a3adf7ad --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0557f6a5a14d73a478ce95ac1798a66b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index ef519ac0..772228aa 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -502,6 +502,190 @@ public async Task QueryThreadsAsync(StreamQueryThrea }; } + public async Task SearchMessagesAsync( + StreamSearchMessagesRequest request, + CancellationToken cancellationToken = default(CancellationToken)) + { + ValidateSearchMessagesRequest(request); + + cancellationToken.ThrowIfCancellationRequested(); + + var requestDto = request.TrySaveToDto(); + var responseDto = + await InternalLowLevelClient.InternalMessageApi.SearchMessagesAsync(requestDto); + + cancellationToken.ThrowIfCancellationRequested(); + + var results = new List(); + var distinctChannels = new Dictionary(); + + if (responseDto?.Results != null) + { + foreach (var resultDto in responseDto.Results) + { + var searchMsgDto = resultDto?.Message; + if (searchMsgDto == null) + { + continue; + } + + IStreamChannel channel = null; + if (searchMsgDto.Channel != null) + { + channel = _cache.TryCreateOrUpdate(searchMsgDto.Channel); + if (channel != null && !distinctChannels.ContainsKey(channel.Cid)) + { + distinctChannels.Add(channel.Cid, channel); + } + } + + var messageDto = ProjectSearchResultToMessageDto(searchMsgDto); + var message = _cache.TryCreateOrUpdate(messageDto); + + results.Add(new StreamSearchMessageResult + { + Message = message, + Channel = channel, + }); + } + } + + if (request.WatchResultChannels && distinctChannels.Count > 0) + { + foreach (var channel in distinctChannels.Values) + { + cancellationToken.ThrowIfCancellationRequested(); + + //StreamTodo: parallelise once cancellation is plumbed; serial keeps load predictable for now. + await InternalGetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + } + } + + return new StreamSearchMessagesResponse + { + Results = results, + Next = responseDto?.Next, + Previous = responseDto?.Previous, + Duration = responseDto?.Duration, + ResultsWarning = BuildSearchWarning(responseDto?.ResultsWarning), + }; + } + + private static void ValidateSearchMessagesRequest(StreamSearchMessagesRequest request) + { + StreamAsserts.AssertNotNull(request, nameof(request)); + + var hasChannelFilter = request.ChannelFilter != null && request.ChannelFilter.Any(); + if (!hasChannelFilter) + { + throw new ArgumentException( + "ChannelFilter is required for SearchMessagesAsync. Add at least one rule, " + + "e.g. ChannelFilter.Members.In(Client.LocalUserData.User).", + nameof(request)); + } + + if (request.Offset.HasValue && !string.IsNullOrEmpty(request.Next)) + { + throw new ArgumentException( + "Offset and Next pagination are mutually exclusive on SearchMessagesAsync.", + nameof(request)); + } + + if (request.Sort != null && request.Offset.HasValue && request.Offset.Value > 0) + { + throw new ArgumentException( + "Sort cannot be combined with a non-zero Offset on SearchMessagesAsync. " + + "Use the Next cursor for sorted pagination.", + nameof(request)); + } + + if (request.Limit.HasValue && request.Limit.Value < 1) + { + throw new ArgumentOutOfRangeException(nameof(request), + "Limit must be greater than or equal to 1."); + } + + if (!string.IsNullOrEmpty(request.Query) && request.MessageFilter != null) + { + foreach (var rule in request.MessageFilter) + { + if (rule != null && rule.Field == "text") + { + throw new ArgumentException( + "Query and a MessageFilter rule on the 'text' field cannot be combined. " + + "Pick one - either pass a free-text Query, or filter by MessageFilter.Text.", + nameof(request)); + } + } + } + } + + private static MessageInternalDTO ProjectSearchResultToMessageDto(SearchResultMessageInternalDTO source) + { + // Project the search-specific payload onto the canonical message DTO so that the cache + // can reuse the existing StreamMessage create/update path. Every field on + // SearchResultMessageInternalDTO has a one-to-one counterpart on MessageInternalDTO + // except for the embedded Channel, which is cached separately. + return new MessageInternalDTO + { + Attachments = source.Attachments, + BeforeMessageSendFailed = source.BeforeMessageSendFailed, + Cid = source.Cid, + Command = source.Command, + CreatedAt = source.CreatedAt, + Custom = source.Custom, + DeletedAt = source.DeletedAt, + DeletedReplyCount = source.DeletedReplyCount, + Html = source.Html, + I18n = source.I18n, + Id = source.Id, + ImageLabels = source.ImageLabels, + LatestReactions = source.LatestReactions, + MentionedUsers = source.MentionedUsers, + MessageTextUpdatedAt = source.MessageTextUpdatedAt, + Mml = source.Mml, + OwnReactions = source.OwnReactions, + ParentId = source.ParentId, + PinExpires = source.PinExpires, + Pinned = source.Pinned, + PinnedAt = source.PinnedAt, + PinnedBy = source.PinnedBy, + Poll = source.Poll, + PollId = source.PollId, + QuotedMessage = source.QuotedMessage, + QuotedMessageId = source.QuotedMessageId, + ReactionCounts = source.ReactionCounts, + ReactionGroups = source.ReactionGroups, + ReactionScores = source.ReactionScores, + ReplyCount = source.ReplyCount, + Shadowed = source.Shadowed, + ShowInChannel = source.ShowInChannel, + Silent = source.Silent, + Text = source.Text, + ThreadParticipants = source.ThreadParticipants, + Type = source.Type, + UpdatedAt = source.UpdatedAt, + User = source.User, + AdditionalProperties = source.AdditionalProperties, + }; + } + + private static StreamSearchWarning BuildSearchWarning(SearchWarningInternalDTO dto) + { + if (dto == null) + { + return null; + } + + return new StreamSearchWarning + { + Code = dto.WarningCode, + Description = dto.WarningDescription, + ChannelSearchCount = dto.ChannelSearchCount, + ChannelIds = dto.ChannelSearchCids, + }; + } + public Task> UpsertUsersAsync(IEnumerable userRequests) => UpsertUsers(userRequests); From 2eefbcafa46337bdcaaf2ed3b3c262acb0de24af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 20 May 2026 14:13:00 +0200 Subject: [PATCH 02/19] Add tests for search feature --- .../StatefulClient/SearchMessagesTests.cs | 794 ++++++++++++++++++ .../SearchMessagesTests.cs.meta | 11 + 2 files changed, 805 insertions(+) create mode 100644 Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs create mode 100644 Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs new file mode 100644 index 00000000..34ac7768 --- /dev/null +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -0,0 +1,794 @@ +#if STREAM_TESTS_ENABLED +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using StreamChat.Core.LowLevelClient; +using StreamChat.Core.QueryBuilders.Filters; +using StreamChat.Core.QueryBuilders.Filters.Channels; +using StreamChat.Core.QueryBuilders.Filters.Messages; +using StreamChat.Core.QueryBuilders.Sort; +using StreamChat.Core.Requests; +using StreamChat.Core.StatefulModels; +using UnityEngine.TestTools; + +namespace StreamChat.Tests.StatefulClient +{ + /// + /// Tests for . + /// + /// + /// Coverage matches the test plan in docs/specs/search-messages.md: + /// integration scenarios for the most important use cases plus client-side validation + /// rules and filter / sort builder shape assertions. + /// + /// + internal class SearchMessagesTests : BaseStateIntegrationTests + { + // --------------------------------------------------------------------- + // Builder shape tests (no live server, no connection required) + // --------------------------------------------------------------------- + + [Test] + public void When_message_filter_mentioned_user_id_contains_then_field_and_operator_are_correct() + { + var entry = MessageFilter.MentionedUserId.Contains("bob").GenerateFilterEntry(); + Assert.AreEqual("mentioned_users.id", entry.Key); + AssertOperator(entry, "$contains", "bob"); + } + + [Test] + public void When_message_filter_attachment_type_in_then_field_and_operator_are_correct() + { + var rule = MessageFilter.AttachmentType.In(new[] { "image", "video" }); + Assert.AreEqual("attachments.type", rule.Field); + + var entry = rule.GenerateFilterEntry(); + AssertOperator(entry, "$in", new[] { "image", "video" }); + } + + [Test] + public void When_message_filter_created_at_gte_then_field_and_operator_are_correct() + { + var when = new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero); + var rule = MessageFilter.CreatedAt.GreaterThanOrEquals(when); + Assert.AreEqual("created_at", rule.Field); + + var entry = rule.GenerateFilterEntry(); + var op = (IDictionary)entry.Value; + Assert.IsTrue(op.ContainsKey("$gte")); + } + + [Test] + public void When_message_filter_parent_id_exists_then_field_and_operator_are_correct() + { + var entry = MessageFilter.ParentId.Exists(true).GenerateFilterEntry(); + Assert.AreEqual("parent_id", entry.Key); + AssertOperator(entry, "$exists", true); + } + + [Test] + public void When_message_filter_pinned_equals_then_field_and_operator_are_correct() + { + var entry = MessageFilter.Pinned.EqualsTo(true).GenerateFilterEntry(); + Assert.AreEqual("pinned", entry.Key); + AssertOperator(entry, "$eq", true); + } + + [Test] + public void When_message_filter_silent_equals_then_field_and_operator_are_correct() + { + var entry = MessageFilter.Silent.EqualsTo(false).GenerateFilterEntry(); + Assert.AreEqual("silent", entry.Key); + AssertOperator(entry, "$eq", false); + } + + [Test] + public void When_message_filter_type_equals_then_field_and_operator_are_correct() + { + var entry = MessageFilter.Type.EqualsTo("regular").GenerateFilterEntry(); + Assert.AreEqual("type", entry.Key); + AssertOperator(entry, "$eq", "regular"); + } + + [Test] + public void When_message_filter_user_id_in_then_field_and_operator_are_correct() + { + var entry = MessageFilter.UserId.In(new[] { "alice", "bob" }).GenerateFilterEntry(); + Assert.AreEqual("user.id", entry.Key); + AssertOperator(entry, "$in", new[] { "alice", "bob" }); + } + + [Test] + public void When_message_filter_custom_field_equals_then_uses_supplied_field_name() + { + var entry = MessageFilter.Custom("priority").EqualsTo("high").GenerateFilterEntry(); + Assert.AreEqual("priority", entry.Key); + AssertOperator(entry, "$eq", "high"); + } + + [Test] + public void When_messages_sort_order_by_descending_created_at_then_dto_contains_field_and_minus_one_direction() + { + var sort = MessagesSort.OrderByDescending(MessageSortFieldName.CreatedAt); + var dto = sort.ToSortParamRequestList(); + + Assert.IsNotNull(dto); + Assert.AreEqual(1, dto.Count); + Assert.AreEqual("created_at", dto[0].Field); + Assert.AreEqual(-1, dto[0].Direction); + } + + [Test] + public void When_messages_sort_then_by_ascending_then_dto_contains_both_entries_in_order() + { + var sort = MessagesSort + .OrderByDescending(MessageSortFieldName.CreatedAt) + .ThenByAscending(MessageSortFieldName.Id); + + var dto = sort.ToSortParamRequestList(); + + Assert.IsNotNull(dto); + Assert.AreEqual(2, dto.Count); + Assert.AreEqual("created_at", dto[0].Field); + Assert.AreEqual(-1, dto[0].Direction); + Assert.AreEqual("id", dto[1].Field); + Assert.AreEqual(1, dto[1].Direction); + } + + [Test] + public void When_request_save_to_dto_then_channel_and_message_filters_are_separated() + { + var request = new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo("messaging:abc"), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.MentionedUserId.Contains("bob"), + }, + Query = "hello", + Limit = 30, + Offset = 0, + }; + + var dto = ((ISavableTo)request) + .SaveToDto(); + + Assert.IsNotNull(dto.FilterConditions); + Assert.IsTrue(dto.FilterConditions.ContainsKey("cid")); + + Assert.IsNotNull(dto.MessageFilterConditions); + Assert.IsTrue(dto.MessageFilterConditions.ContainsKey("mentioned_users.id")); + + Assert.AreEqual("hello", dto.Query); + Assert.AreEqual(30, dto.Limit); + Assert.AreEqual(0, dto.Offset); + } + + // --------------------------------------------------------------------- + // Client-side validation tests + // --------------------------------------------------------------------- + + [UnityTest] + public IEnumerator When_search_with_null_request_expect_throws() + => ConnectAndExecute(When_search_with_null_request_expect_throws_Async); + + private async Task When_search_with_null_request_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(null)); + } + + [UnityTest] + public IEnumerator When_search_with_null_channel_filter_expect_throws() + => ConnectAndExecute(When_search_with_null_channel_filter_expect_throws_Async); + + private async Task When_search_with_null_channel_filter_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = null, + Query = "anything", + })); + } + + [UnityTest] + public IEnumerator When_search_with_empty_channel_filter_expect_throws() + => ConnectAndExecute(When_search_with_empty_channel_filter_expect_throws_Async); + + private async Task When_search_with_empty_channel_filter_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[0], + Query = "anything", + })); + } + + [UnityTest] + public IEnumerator When_search_with_offset_and_next_both_set_expect_throws() + => ConnectAndExecute(When_search_with_offset_and_next_both_set_expect_throws_Async); + + private async Task When_search_with_offset_and_next_both_set_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + Offset = 30, + Next = "fake-cursor", + })); + } + + [UnityTest] + public IEnumerator When_search_with_sort_and_non_zero_offset_expect_throws() + => ConnectAndExecute(When_search_with_sort_and_non_zero_offset_expect_throws_Async); + + private async Task When_search_with_sort_and_non_zero_offset_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + Offset = 30, + Sort = MessagesSort.OrderByDescending(MessageSortFieldName.CreatedAt), + })); + } + + [UnityTest] + public IEnumerator When_search_with_query_and_text_message_filter_expect_throws() + => ConnectAndExecute(When_search_with_query_and_text_message_filter_expect_throws_Async); + + private async Task When_search_with_query_and_text_message_filter_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "hello", + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.Text.Contains("hello"), + }, + })); + } + + [UnityTest] + public IEnumerator When_search_with_limit_below_one_expect_throws() + => ConnectAndExecute(When_search_with_limit_below_one_expect_throws_Async); + + private async Task When_search_with_limit_below_one_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + Limit = 0, + })); + } + + [UnityTest] + public IEnumerator When_search_with_cancelled_token_expect_throws_operation_cancelled() + => ConnectAndExecute(When_search_with_cancelled_token_expect_throws_operation_cancelled_Async); + + private async Task When_search_with_cancelled_token_expect_throws_operation_cancelled_Async() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + }, cts.Token)); + } + + // --------------------------------------------------------------------- + // Integration tests (require live server) + // --------------------------------------------------------------------- + + [UnityTest] + public IEnumerator When_search_by_mentioned_user_id_expect_only_messages_mentioning_that_user() + => ConnectAndExecute(When_search_by_mentioned_user_id_expect_only_messages_mentioning_that_user_Async); + + private async Task When_search_by_mentioned_user_id_expect_only_messages_mentioning_that_user_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var userToMention = await CreateUniqueTempUserAsync("Michael"); + + await channel.SendNewMessageAsync("Hello"); + await channel.SendNewMessageAsync("How are you"); + var mentionMessage = await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Hey there!", + MentionedUsers = new List { userToMention }, + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.MentionedUserId.Contains(userToMention.Id), + }, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == mentionMessage.Id)); + + Assert.IsNotEmpty(response.Results); + var hit = response.Results.FirstOrDefault(r => r.Message.Id == mentionMessage.Id); + Assert.IsNotNull(hit, "Expected to find the mention message in search results."); + + // Spec 4.3 + 5.3: results expose stateful IStreamMessage + IStreamChannel. + Assert.IsInstanceOf(hit.Message); + Assert.IsInstanceOf(hit.Channel); + Assert.AreEqual(channel.Cid, hit.Channel.Cid); + } + + [UnityTest] + public IEnumerator When_search_with_query_text_expect_matching_message_returned() + => ConnectAndExecute(When_search_with_query_text_expect_matching_message_returned_Async); + + private async Task When_search_with_query_text_expect_matching_message_returned_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var unique = "needle-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var matching = await channel.SendNewMessageAsync("Special content with " + unique); + await channel.SendNewMessageAsync("Plain message without the token"); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = unique, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == matching.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == matching.Id)); + Assert.IsTrue(response.Results.All(r => r.Channel != null && r.Channel.Cid == channel.Cid), + "All hits should belong to the requested channel."); + } + + [UnityTest] + public IEnumerator When_search_restricted_by_single_cid_expect_only_that_channels_messages() + => ConnectAndExecute(When_search_restricted_by_single_cid_expect_only_that_channels_messages_Async); + + private async Task When_search_restricted_by_single_cid_expect_only_that_channels_messages_Async() + { + var channelA = await CreateUniqueTempChannelAsync(); + var channelB = await CreateUniqueTempChannelAsync(); + + var token = "scoped-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var msgInA = await channelA.SendNewMessageAsync("In A: " + token); + await channelB.SendNewMessageAsync("In B: " + token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channelA.Cid), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msgInA.Id)); + + Assert.IsNotEmpty(response.Results); + Assert.IsTrue(response.Results.All(r => r.Channel.Cid == channelA.Cid), + "Only messages from channelA should be returned when Cid filter restricts to it."); + } + + [UnityTest] + public IEnumerator When_search_returns_message_already_in_watched_channel_expect_same_instance() + => ConnectAndExecute(When_search_returns_message_already_in_watched_channel_expect_same_instance_Async); + + private async Task When_search_returns_message_already_in_watched_channel_expect_same_instance_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "identity-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sent = await channel.SendNewMessageAsync(token); + + // The sent message lives in channel.Messages (the channel is watched). + var cached = channel.Messages.First(m => m.Id == sent.Id); + Assert.AreSame(sent, cached); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == sent.Id)); + + var hit = response.Results.First(r => r.Message.Id == sent.Id); + + // Spec 6.1: cache identity - the search hit is the exact same instance. + Assert.AreSame(cached, hit.Message, + "Search hit Message should be the same cached instance as channel.Messages."); + Assert.AreSame(channel, hit.Channel, + "Search hit Channel should be the same cached instance as the watched channel."); + } + + [UnityTest] + public IEnumerator When_search_with_custom_field_filter_expect_matching_messages() + => ConnectAndExecute(When_search_with_custom_field_filter_expect_matching_messages_Async); + + private async Task When_search_with_custom_field_filter_expect_matching_messages_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + // Use a unique custom field name per run to avoid cross-test interference on shared indices. + var customKey = "test_priority_" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var high = await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "High priority message", + CustomData = new StreamCustomDataRequest { { customKey, "high" } } + }); + + await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Low priority message", + CustomData = new StreamCustomDataRequest { { customKey, "low" } } + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.Custom(customKey).EqualsTo("high"), + }, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == high.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == high.Id)); + Assert.IsTrue(response.Results.All(r => + r.Message.CustomData != null && + r.Message.CustomData.Get(customKey) == "high"), + "Every result should have the custom field set to 'high'."); + } + + [UnityTest] + public IEnumerator When_search_with_parent_id_exists_true_expect_only_replies() + => ConnectAndExecute(When_search_with_parent_id_exists_true_expect_only_replies_Async); + + private async Task When_search_with_parent_id_exists_true_expect_only_replies_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "replies-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var parent = await channel.SendNewMessageAsync("Parent " + token); + var reply = await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Reply " + token, + ParentId = parent.Id, + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.ParentId.Exists(true), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == reply.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == reply.Id)); + Assert.IsTrue(response.Results.All(r => !string.IsNullOrEmpty(r.Message.ParentId)), + "ParentId.Exists(true) should only return reply messages."); + Assert.IsFalse(response.Results.Any(r => r.Message.Id == parent.Id), + "Parent message should not be returned when filtering for replies only."); + } + + [UnityTest] + public IEnumerator When_search_with_parent_id_exists_false_expect_only_top_level_messages() + => ConnectAndExecute(When_search_with_parent_id_exists_false_expect_only_top_level_messages_Async); + + private async Task When_search_with_parent_id_exists_false_expect_only_top_level_messages_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "toplevel-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var parent = await channel.SendNewMessageAsync("Parent " + token); + await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Reply " + token, + ParentId = parent.Id, + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.ParentId.Exists(false), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == parent.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == parent.Id)); + Assert.IsTrue(response.Results.All(r => string.IsNullOrEmpty(r.Message.ParentId)), + "ParentId.Exists(false) should only return top-level (non-reply) messages."); + } + + [UnityTest] + public IEnumerator When_search_with_date_range_expect_only_messages_within_range() + => ConnectAndExecute(When_search_with_date_range_expect_only_messages_within_range_Async); + + private async Task When_search_with_date_range_expect_only_messages_within_range_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "daterange-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var lowerBound = DateTimeOffset.UtcNow.AddMinutes(-2); + var msg = await channel.SendNewMessageAsync("In window: " + token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.CreatedAt.GreaterThanOrEquals(lowerBound), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == msg.Id)); + Assert.IsTrue(response.Results.All(r => r.Message.CreatedAt >= lowerBound.AddSeconds(-5)), + "All returned messages must have CreatedAt >= the lower bound (small allowance for clock skew)."); + } + + [UnityTest] + public IEnumerator When_search_with_sort_descending_expect_results_monotonically_decreasing() + => ConnectAndExecute(When_search_with_sort_descending_expect_results_monotonically_decreasing_Async); + + private async Task When_search_with_sort_descending_expect_results_monotonically_decreasing_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "sortdesc-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sentIds = new List(); + for (var i = 0; i < 3; i++) + { + var m = await channel.SendNewMessageAsync("Msg " + i + " " + token); + sentIds.Add(m.Id); + } + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Sort = MessagesSort.OrderByDescending(MessageSortFieldName.CreatedAt), + }), r => r != null && r.Results != null && r.Results.Count >= sentIds.Count); + + // We only assert ordering on results that belong to this run (matched by the unique token). + var ours = response.Results + .Where(r => sentIds.Contains(r.Message.Id)) + .ToList(); + + Assert.AreEqual(sentIds.Count, ours.Count, "Expected all messages from this run to be returned."); + + for (var i = 1; i < ours.Count; i++) + { + Assert.IsTrue(ours[i - 1].Message.CreatedAt >= ours[i].Message.CreatedAt, + "Descending sort: each subsequent CreatedAt should be <= the previous."); + } + } + + [UnityTest] + public IEnumerator When_search_with_limit_then_response_capped_at_limit() + => ConnectAndExecute(When_search_with_limit_then_response_capped_at_limit_Async); + + private async Task When_search_with_limit_then_response_capped_at_limit_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "limit-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + for (var i = 0; i < 3; i++) + { + await channel.SendNewMessageAsync("Msg " + i + " " + token); + } + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 1, + }), r => r != null && r.Results != null && r.Results.Count >= 1); + + Assert.AreEqual(1, response.Results.Count, + "Limit=1 should return at most one message per page."); + } + + [UnityTest] + public IEnumerator When_search_with_cursor_pagination_expect_next_cursor_and_disjoint_pages() + => ConnectAndExecute(When_search_with_cursor_pagination_expect_next_cursor_and_disjoint_pages_Async); + + private async Task When_search_with_cursor_pagination_expect_next_cursor_and_disjoint_pages_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "cursor-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sentIds = new List(); + for (var i = 0; i < 3; i++) + { + var m = await channel.SendNewMessageAsync("Msg " + i + " " + token); + sentIds.Add(m.Id); + } + + var page1 = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 1, + Sort = MessagesSort.OrderByAscending(MessageSortFieldName.CreatedAt), + }), r => r != null && r.Results != null && r.Results.Count == 1 && !string.IsNullOrEmpty(r.Next)); + + Assert.IsFalse(string.IsNullOrEmpty(page1.Next), "Page 1 must return a Next cursor."); + + var page2 = await Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 1, + Sort = MessagesSort.OrderByAscending(MessageSortFieldName.CreatedAt), + Next = page1.Next, + }); + + Assert.IsNotNull(page2); + Assert.IsNotEmpty(page2.Results); + + var page1Ids = new HashSet(page1.Results.Select(r => r.Message.Id)); + Assert.IsFalse(page2.Results.Any(r => page1Ids.Contains(r.Message.Id)), + "Page 2 must not contain any message from page 1."); + } + + [UnityTest] + public IEnumerator When_search_returns_message_and_then_soft_deleted_expect_hit_reflects_deletion() + => ConnectAndExecute(When_search_returns_message_and_then_soft_deleted_expect_hit_reflects_deletion_Async); + + private async Task When_search_returns_message_and_then_soft_deleted_expect_hit_reflects_deletion_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "softdel-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sent = await channel.SendNewMessageAsync(token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == sent.Id)); + + var hit = response.Results.First(r => r.Message.Id == sent.Id); + + // Soft-delete via the channel API; cache identity means the search hit gets the update. + await sent.SoftDeleteAsync(); + await WaitWhileTrueAsync(() => !hit.Message.DeletedAt.HasValue, + description: "search hit Message.DeletedAt to be populated after soft-delete"); + + Assert.IsTrue(hit.Message.IsDeleted, "Hit message IsDeleted should be true after soft-delete."); + Assert.IsTrue(hit.Message.DeletedAt.HasValue); + } + + [UnityTest] + public IEnumerator When_search_with_watch_result_channels_true_expect_channel_in_watched_channels() + => ConnectAndExecute(When_search_with_watch_result_channels_true_expect_channel_in_watched_channels_Async); + + private async Task When_search_with_watch_result_channels_true_expect_channel_in_watched_channels_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "watchtrue-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + WatchResultChannels = true, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == msg.Id)); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "With WatchResultChannels=true the hit channel must appear in WatchedChannels."); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static void AssertOperator(KeyValuePair entry, string expectedOperator, + object expectedValue) + { + Assert.IsNotNull(entry.Value, "Filter entry value should not be null."); + var dict = entry.Value as IDictionary; + Assert.IsNotNull(dict, "Filter entry value should serialize to a dictionary."); + Assert.IsTrue(dict.ContainsKey(expectedOperator), + "Expected operator '" + expectedOperator + "' not present. Got: " + + string.Join(",", dict.Keys)); + Assert.AreEqual(expectedValue, dict[expectedOperator]); + } + + private static async Task AssertThrowsAsync(Func action) where TException : Exception + { + try + { + await action(); + } + catch (TException) + { + return; + } + catch (Exception e) + { + Assert.Fail("Expected " + typeof(TException).Name + " but caught " + e.GetType().Name + + ": " + e.Message); + return; + } + + Assert.Fail("Expected " + typeof(TException).Name + " but no exception was thrown."); + } + } +} +#endif diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta new file mode 100644 index 00000000..88cf78ea --- /dev/null +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9148c3f07291328408f6dc9fcafe3958 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 4f479769377e02aceaee746b69ab22bc9484df13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 21 May 2026 15:52:37 +0200 Subject: [PATCH 03/19] Fix test to not use both Query and MessageFilter (disallowed by API) --- .../Requests/StreamSearchMessagesRequest.cs | 101 ++++++++++++++++-- .../StreamChat/Core/StreamChatClient.cs | 19 ++-- .../StatefulClient/SearchMessagesTests.cs | 33 +++++- 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index 74f12a21..9d1f3b3d 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -27,18 +27,107 @@ public sealed class StreamSearchMessagesRequest : ISavableTo ChannelFilter { get; set; } /// - /// Optional. Filter restricting which messages within the matched channels match. Use - /// to build the rules. + /// Optional. Structured, strongly-typed filter applied to individual messages inside the + /// channels selected by . This is the "WHERE" clause for the + /// message itself: only messages that match every rule in the list are returned. /// - /// Mutually exclusive at the text field with ; that combination - /// is rejected client-side before the request is sent. + /// + /// Build rules with . + /// Common use cases include: + /// + /// + /// + /// Mentions of a specific user — MessageFilter.MentionedUserId.Contains(userId). + /// + /// + /// Messages by a given author or set of authors — + /// MessageFilter.UserId.EqualsTo("alice") or + /// MessageFilter.UserId.In(new[] { "alice", "bob" }). + /// + /// + /// Messages of a specific type — MessageFilter.Type.EqualsTo("regular") / + /// "system" / "deleted". + /// + /// + /// Replies only or top-level only — MessageFilter.ParentId.Exists(true) for + /// thread replies, MessageFilter.ParentId.Exists(false) for top-level messages. + /// + /// + /// Date ranges — MessageFilter.CreatedAt.GreaterThanOrEquals(from) combined with + /// MessageFilter.CreatedAt.LessThanOrEquals(to). + /// + /// + /// Attachments of a given type — MessageFilter.AttachmentType.In(new[] { "image", "video" }). + /// + /// + /// Pinned, silent, or polls — MessageFilter.Pinned.EqualsTo(true), + /// MessageFilter.Silent.EqualsTo(false), MessageFilter.PollId.Exists(true). + /// + /// + /// Reactions of a given type — + /// MessageFilter.ReactionType.Contains("fire"). + /// + /// + /// Custom message fields — MessageFilter.Custom("priority").EqualsTo("high"). + /// + /// + /// Text matching as a structured rule — MessageFilter.Text.Contains("invoice"). + /// Use this form when you also need other rules in the same request (see remark below). + /// + /// + /// + /// + /// All rules in the list are combined with logical AND. For OR / NOR combinations, use the + /// compound builders on the filter façade. + /// + /// + /// + /// Remark: mutually exclusive with . The server rejects requests that + /// specify both a free-text query and message_filter_conditions; the SDK + /// catches this client-side and throws . If you need + /// text matching alongside other constraints, drop and use + /// MessageFilter.Text.Contains(...) here instead. + /// /// public IEnumerable MessageFilter { get; set; } /// - /// Optional. Free-text search phrase. Performs full-text search on the message text. + /// Optional. Free-text search phrase executed by the server's full-text search engine + /// against the message body. This is the shortest path to a "search bar" experience — + /// the user types a phrase and the server returns the most relevant matching messages + /// across every channel selected by . + /// + /// + /// Use this when: + /// + /// + /// + /// You only need to match on message text and want server-side ranking (relevance, + /// stemming, fuzzy matching where supported) rather than the literal substring matching + /// of MessageFilter.Text.Contains(...). + /// + /// + /// You are wiring up a generic search input — pass whatever the user typed verbatim. + /// + /// + /// You want to combine free-text search with channel-level constraints only + /// (e.g. "search 'release notes' in channels I'm a member of") — supply + /// rules and leave null. + /// + /// + /// + /// + /// Pair with set to MessagesSort.OrderByDescending(MessageSortFieldName.Relevance) + /// to surface the best matches first. + /// /// - /// Cannot be combined with a rule targeting the text field. + /// + /// Remark: mutually exclusive with . The server rejects + /// requests that specify both; the SDK catches this client-side and throws + /// . If you need to combine text matching with + /// other message-level constraints, omit and express the text rule + /// inside via MessageFilter.Text.Contains(...). + /// /// public string Query { get; set; } diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index 772228aa..cd109fa9 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -605,18 +605,15 @@ private static void ValidateSearchMessagesRequest(StreamSearchMessagesRequest re "Limit must be greater than or equal to 1."); } - if (!string.IsNullOrEmpty(request.Query) && request.MessageFilter != null) + if (!string.IsNullOrEmpty(request.Query) && request.MessageFilter != null && + request.MessageFilter.Any(r => r != null)) { - foreach (var rule in request.MessageFilter) - { - if (rule != null && rule.Field == "text") - { - throw new ArgumentException( - "Query and a MessageFilter rule on the 'text' field cannot be combined. " + - "Pick one - either pass a free-text Query, or filter by MessageFilter.Text.", - nameof(request)); - } - } + throw new ArgumentException( + "Query and MessageFilter cannot be combined on SearchMessagesAsync. " + + "The server rejects requests that specify both a free-text query and " + + "message_filter_conditions. Pick one - either pass a free-text Query, or " + + "express the same constraint via MessageFilter (e.g. MessageFilter.Text.Contains(...)).", + nameof(request)); } } diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index 34ac7768..411e4279 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -272,6 +272,30 @@ await AssertThrowsAsync( })); } + [UnityTest] + public IEnumerator When_search_with_query_and_non_text_message_filter_expect_throws() + => ConnectAndExecute(When_search_with_query_and_non_text_message_filter_expect_throws_Async); + + private async Task When_search_with_query_and_non_text_message_filter_expect_throws_Async() + { + // Server rejects ANY combination of `query` + `message_filter_conditions`, not just on + // the `text` field. The client must surface that as ArgumentException up-front so callers + // don't get a confusing 400 from the server. + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "hello", + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.ParentId.Exists(false), + }, + })); + } + [UnityTest] public IEnumerator When_search_with_limit_below_one_expect_throws() => ConnectAndExecute(When_search_with_limit_below_one_expect_throws_Async); @@ -497,6 +521,8 @@ private async Task When_search_with_parent_id_exists_true_expect_only_replies_As ParentId = parent.Id, }); + // Note: cannot combine Query with MessageFilter (server rejects it). The unique + // channel scope is sufficient to isolate this test's messages. var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] @@ -507,7 +533,6 @@ private async Task When_search_with_parent_id_exists_true_expect_only_replies_As { MessageFilter.ParentId.Exists(true), }, - Query = token, }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == reply.Id)); Assert.IsTrue(response.Results.Any(r => r.Message.Id == reply.Id)); @@ -533,6 +558,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest ParentId = parent.Id, }); + // Note: cannot combine Query with MessageFilter (server rejects it). The unique + // channel scope is sufficient to isolate this test's messages. var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] @@ -543,7 +570,6 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest { MessageFilter.ParentId.Exists(false), }, - Query = token, }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == parent.Id)); Assert.IsTrue(response.Results.Any(r => r.Message.Id == parent.Id)); @@ -563,6 +589,8 @@ private async Task When_search_with_date_range_expect_only_messages_within_range var lowerBound = DateTimeOffset.UtcNow.AddMinutes(-2); var msg = await channel.SendNewMessageAsync("In window: " + token); + // Note: cannot combine Query with MessageFilter (server rejects it). The unique + // channel scope is sufficient to isolate this test's messages. var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] @@ -573,7 +601,6 @@ private async Task When_search_with_date_range_expect_only_messages_within_range { MessageFilter.CreatedAt.GreaterThanOrEquals(lowerBound), }, - Query = token, }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); Assert.IsTrue(response.Results.Any(r => r.Message.Id == msg.Id)); From dde9038990ec0ff857224e57e046aa44d033bce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 11:32:18 +0200 Subject: [PATCH 04/19] Fix date serialization. The API was returning "Search failed with error: "field "created_at" expects type date"" error --- .../QueryBuilders/Filters/FieldFilterRule.cs | 15 ++--- .../Libs/Utils/StreamDateFormatter.cs | 59 +++++++++++++++++++ .../Libs/Utils/StreamDateFormatter.cs.meta | 11 ++++ .../StreamChat/Tests/UnityTestUtils.cs | 4 -- 4 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs create mode 100644 Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs index ef029f2d..d8c14b3c 100644 --- a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using StreamChat.Libs.Utils; namespace StreamChat.Core.QueryBuilders.Filters { @@ -36,14 +36,14 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, DateTime va { Field = field; OperatorType = operatorType; - Value = ToRfc3339String(value); + Value = value.ToStreamDateString(); } public FieldFilterRule(string field, QueryOperatorType operatorType, DateTimeOffset value) { Field = field; OperatorType = operatorType; - Value = ToRfc3339String(value); + Value = value.ToStreamDateString(); } public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) @@ -57,14 +57,14 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable { Field = field; OperatorType = operatorType; - Value = value.ToArray(); + Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); } public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) { Field = field; OperatorType = operatorType; - Value = value.ToArray(); + Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); } //StreamTodo: research how to reduce allocation here @@ -79,10 +79,5 @@ public KeyValuePair GenerateFilterEntry() } ); - private static string ToRfc3339String(DateTime dateTime) - => dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); - - private static string ToRfc3339String(DateTimeOffset dateTimeOffset) - => dateTimeOffset.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); } } \ No newline at end of file diff --git a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs new file mode 100644 index 00000000..b18f4015 --- /dev/null +++ b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs @@ -0,0 +1,59 @@ +using System; +using System.Globalization; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StreamChat.Core")] + +namespace StreamChat.Libs.Utils +{ + /// + /// Formats / values in the canonical + /// Stream API format: yyyy-MM-ddTHH:mm:ss.fffZ (UTC, millisecond precision, literal "Z"). + /// + /// This matches the format used by all other Stream SDKs (see + /// StreamDateFormatter in stream-chat-android) and is the only form accepted by every + /// Stream endpoint. In particular, the /search endpoint's message_filter_conditions + /// rejects the numeric-offset form (+00:00) with + /// "field "created_at" expects type date", so any date sent to the API must go through + /// this formatter to stay portable across endpoints. + /// + internal static class StreamDateFormatter + { + // Equivalent to Java's "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" used by the Android SDK. + private const string DateFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + + /// + /// Formats in the canonical Stream API format. + /// The value is normalised to UTC before formatting: + /// is used as-is, is + /// converted via , and + /// is assumed to already be UTC. + /// + internal static string ToStreamDateString(this DateTime dateTime) + { + DateTime utc; + switch (dateTime.Kind) + { + case DateTimeKind.Utc: + utc = dateTime; + break; + case DateTimeKind.Local: + utc = dateTime.ToUniversalTime(); + break; + default: + utc = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + break; + } + + return utc.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + } + + /// + /// Formats in the canonical Stream API format. + /// The value is converted to UTC before formatting, so the wire output always ends in + /// Z regardless of the source offset. + /// + internal static string ToStreamDateString(this DateTimeOffset dateTimeOffset) + => dateTimeOffset.UtcDateTime.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + } +} diff --git a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta new file mode 100644 index 00000000..eb201c7c --- /dev/null +++ b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3fe2ad1e7e9d4d859afea1dc11593bc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs b/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs index 67cae6ff..964b4e28 100644 --- a/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs +++ b/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs @@ -1,7 +1,6 @@ #if STREAM_TESTS_ENABLED using System; using System.Collections; -using System.Globalization; using System.Threading.Tasks; using StreamChat.Core; using StreamChat.Core.LowLevelClient; @@ -144,9 +143,6 @@ public static IEnumerator RunTaskAsEnumerator(this Task task) throw task.Exception; } - public static string ToRfc3339String(this DateTime dateTime) - => dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); - private static Exception UnwrapAggregateException(Exception exception) { if (exception is AggregateException aggregateException && From 5958cfae932e9695a33d946a3be208cbb5894073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 12:10:01 +0200 Subject: [PATCH 05/19] Fix "TearDown : System.ArgumentException : 'async void' methods are not supported, please use 'async Task' instead" --- .../Tests/StatefulClient/BaseStateIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs index 5533f67b..61518626 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs @@ -28,7 +28,7 @@ public void OneTimeUp() } [OneTimeTearDown] - public async void OneTimeTearDown() + public async Task OneTimeTearDown() { Debug.Log("------------ TearDown"); From c95e348f2c644bbbbb98b5b5d50a7148dc936137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 13:10:14 +0200 Subject: [PATCH 06/19] Fix tests deadlock --- .../BaseStateIntegrationTests.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs index 61518626..cb8dbcb2 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs @@ -28,12 +28,28 @@ public void OneTimeUp() } [OneTimeTearDown] - public async Task OneTimeTearDown() + public void OneTimeTearDown() { Debug.Log("------------ TearDown"); - await DeleteTempChannelsAsync(); - await StreamTestClients.Instance.RemoveLockAsync(this); + // NUnit drives an `async Task` OneTimeTearDown by blocking the main thread on the + // returned task (effectively `task.GetAwaiter().GetResult()`). Any `await` inside + // captures Unity's UnitySynchronizationContext and posts its continuation back to + // the main thread, which is the very thread NUnit is blocking - classic async-over- + // sync deadlock. Symptom: `Debug.Log("------------ TearDown")` is the last log line + // in Editor.log and Unity hangs with no further output (the kicked-off DELETE + // /channels HTTP call completes, but its continuation never gets to resume). + // + // We can't go back to `async void` (NUnit rejects it with `ArgumentException: + // 'async void' methods are not supported`). Hopping the cleanup onto the thread + // pool detaches it from the Unity SynchronizationContext, so the awaited + // continuations resume on thread-pool threads and the main thread is only + // blocked waiting for a task that no longer needs it. + Task.Run(async () => + { + await DeleteTempChannelsAsync(); + await StreamTestClients.Instance.RemoveLockAsync(this); + }).GetAwaiter().GetResult(); } protected static StreamChatClient Client => StreamTestClients.Instance.StateClient; From af2a824962f52f0f487b8d1e6be07c6cf160006b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 14:02:45 +0200 Subject: [PATCH 07/19] Make test more resilient to changes not being immediately available on the backend --- .../StatefulClient/SearchMessagesTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index 411e4279..752d9000 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -692,7 +692,24 @@ private async Task When_search_with_cursor_pagination_expect_next_cursor_and_dis sentIds.Add(m.Id); } - var page1 = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + // Stream's search index is eventually consistent. Wait until ALL three messages + // are searchable BEFORE testing cursor pagination - otherwise the server happily + // returns a single result without a `next` cursor (because, from its point of view, + // there are no more pages yet) and the cursor predicate below would race against + // indexing for a long time. + await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 30, + }), r => r != null && r.Results != null && + sentIds.All(id => r.Results.Any(x => x.Message != null && x.Message.Id == id)), + description: "all 3 messages to be indexed for cursor pagination test"); + + var page1 = await Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] { @@ -701,8 +718,10 @@ private async Task When_search_with_cursor_pagination_expect_next_cursor_and_dis Query = token, Limit = 1, Sort = MessagesSort.OrderByAscending(MessageSortFieldName.CreatedAt), - }), r => r != null && r.Results != null && r.Results.Count == 1 && !string.IsNullOrEmpty(r.Next)); + }); + Assert.IsNotNull(page1); + Assert.AreEqual(1, page1.Results.Count, "Page 1 must contain exactly Limit=1 result."); Assert.IsFalse(string.IsNullOrEmpty(page1.Next), "Page 1 must return a Next cursor."); var page2 = await Client.SearchMessagesAsync(new StreamSearchMessagesRequest From 6f6d61f3f4bd8749ea29e35a1c59d4f4892651fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 14:17:59 +0200 Subject: [PATCH 08/19] Apply API response to cache when soft delete is used so that the local user state doesn't need to wait for WS event --- .../Core/StatefulModels/StreamMessage.cs | 17 +++- .../BaseStateIntegrationTests.cs | 2 + .../Tests/StatefulClient/MessagesTests.cs | 86 ++++++++++++++++++- .../StatefulClient/SearchMessagesTests.cs | 8 +- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs index 6e813c9c..8e896484 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs @@ -92,8 +92,21 @@ internal sealed class StreamMessage : StreamStatefulModelBase, public bool IsDeleted => Type == MessageType.Deleted; - //Do not update message from response, the WS event might have been processed and we would overwrite it with an old state - public Task SoftDeleteAsync() => LowLevelClient.InternalMessageApi.DeleteMessageAsync(Id, hard: false); + // Apply the REST response to the cache so callers don't have to wait for the + // `message.deleted` WS event before observing `DeletedAt` / `IsDeleted` / cleared + // text on this very instance. The WS event still fires on watchers (including this + // client) and goes through StreamChannel.HandleMessageDeletedEvent; that path is + // idempotent against the state we set here, so a late-arriving event won't regress + // the message back to a non-deleted state. + public async Task SoftDeleteAsync() + { + var response = await LowLevelClient.InternalMessageApi.DeleteMessageAsync(Id, hard: false); + if (response?.Message != null) + { + Cache.TryCreateOrUpdate(response.Message); + } + InternalHandleSoftDelete(); + } //Do not update message from response, the WS event might have been processed and we would overwrite it with an old state public Task HardDeleteAsync() => LowLevelClient.InternalMessageApi.DeleteMessageAsync(Id, hard: true); diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs index cb8dbcb2..e3c9d322 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs @@ -234,6 +234,8 @@ private sealed class WaitProgressLogger { private static readonly TimeSpan[] Thresholds = { + TimeSpan.FromMinutes(0.5), + TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs index 467d3625..7c8be7ba 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs @@ -139,13 +139,95 @@ public async Task When_message_soft_delete_message_expect_text_cleared_Async() var messageInChannel = channel.Messages.FirstOrDefault(_ => _.Id == sentMessage.Id); Assert.NotNull(messageInChannel); + // SoftDeleteAsync applies the REST response to the cache before returning, + // so DeletedAt and the cleared text are visible immediately on the same + // instance the customer is holding. No need to wait for the WS event here - + // see When_other_client_soft_deletes_message_expect_message_deleted_event_observed + // for the watcher-side WS verification. await messageInChannel.SoftDeleteAsync(); - await WaitWhileTrueAsync(() => !messageInChannel.DeletedAt.HasValue); - Assert.NotNull(messageInChannel); Assert.IsNotNull(messageInChannel.DeletedAt); Assert.IsEmpty(messageInChannel.Text); + Assert.IsTrue(messageInChannel.IsDeleted); + } + + /// + /// Companion to : + /// the deleter applies the REST response to its own cache synchronously, so the + /// only path we still need WS coverage for is *another* watching client. This + /// test asserts that a watcher sees the soft-delete via the `message.deleted` + /// WS event - both as a `MessageDeleted` channel event and as state on the + /// cached . + /// + [UnityTest] + public IEnumerator When_other_client_soft_deletes_message_expect_message_deleted_event_observed() + => ConnectAndExecute(When_other_client_soft_deletes_message_expect_message_deleted_event_observed_Async); + + private async Task When_other_client_soft_deletes_message_expect_message_deleted_event_observed_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + + var channel = await CreateUniqueTempChannelAsync(); + + // Watch the same channel from `otherClient` so it receives WS events for it. + var otherClientChannel = await otherClient.GetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + Assert.AreEqual(channel.Cid, otherClientChannel.Cid); + + const string MessageText = "to-be-soft-deleted"; + var sentMessage = await channel.SendNewMessageAsync(MessageText); + + // Wait until the watcher has observed the new message via WS so we have + // a stateful instance to assert on once it's deleted. Without this, a + // very fast soft-delete could race the `message.new` delivery and we'd + // miss the message entirely on `otherClient`. + await WaitWhileFalseAsync( + () => otherClientChannel.Messages.Any(m => m.Id == sentMessage.Id), + description: "watcher to receive message.new for the message about to be deleted"); + + var observedOnOther = otherClientChannel.Messages.Single(m => m.Id == sentMessage.Id); + + var deletedEventCount = 0; + IStreamMessage eventMessage = null; + bool? eventIsHardDelete = null; + + void OnDeleted(IStreamChannel ch, IStreamMessage msg, bool isHardDelete) + { + if (msg.Id != sentMessage.Id) + { + return; + } + + deletedEventCount++; + eventMessage = msg; + eventIsHardDelete = isHardDelete; + } + + otherClientChannel.MessageDeleted += OnDeleted; + try + { + await sentMessage.SoftDeleteAsync(); + + await WaitWhileFalseAsync( + () => deletedEventCount > 0, + description: "watcher to receive message.deleted WS event after soft-delete"); + + Assert.AreEqual(1, deletedEventCount, "MessageDeleted should fire exactly once on the watcher."); + Assert.IsFalse(eventIsHardDelete.GetValueOrDefault(true), + "WS event must report this as a soft-delete (isHardDelete=false)."); + Assert.AreSame(observedOnOther, eventMessage, + "Event must surface the same cached message instance the watcher already holds."); + Assert.IsTrue(observedOnOther.IsDeleted, + "Watcher's cached message should be flagged as deleted after WS event."); + Assert.IsTrue(observedOnOther.DeletedAt.HasValue, + "Watcher's cached message should have DeletedAt populated after WS event."); + Assert.IsEmpty(observedOnOther.Text, + "Watcher's cached message text should be cleared after soft-delete WS event."); + } + finally + { + otherClientChannel.MessageDeleted -= OnDeleted; + } } [UnityTest] diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index 752d9000..28a4dc1a 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -766,13 +766,15 @@ private async Task When_search_returns_message_and_then_soft_deleted_expect_hit_ var hit = response.Results.First(r => r.Message.Id == sent.Id); - // Soft-delete via the channel API; cache identity means the search hit gets the update. + // Search results share cache identity with messages obtained through any other + // surface (here: the freshly-sent `sent`). SoftDeleteAsync applies the REST + // response to the cache before returning, so the search hit reflects the + // deletion on the same instance with no WS round-trip needed. await sent.SoftDeleteAsync(); - await WaitWhileTrueAsync(() => !hit.Message.DeletedAt.HasValue, - description: "search hit Message.DeletedAt to be populated after soft-delete"); Assert.IsTrue(hit.Message.IsDeleted, "Hit message IsDeleted should be true after soft-delete."); Assert.IsTrue(hit.Message.DeletedAt.HasValue); + Assert.AreSame(sent, hit.Message, "Search hit and sent message must be the same cached instance."); } [UnityTest] From ed22ad0945e8f11691cc7329d41791cddc765928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 27 May 2026 11:09:03 +0200 Subject: [PATCH 09/19] Fix inconsistent datetime serialization expected by the API --- .../QueryBuilders/Filters/FieldFilterRule.cs | 91 ++++++++++++-- .../Requests/StreamSearchMessagesRequest.cs | 10 +- .../Libs/Utils/StreamDateFormatter.cs | 111 +++++++++++++++--- 3 files changed, 183 insertions(+), 29 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs index d8c14b3c..a594f0ea 100644 --- a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs @@ -24,7 +24,7 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, string valu OperatorType = operatorType; Value = value; } - + public FieldFilterRule(string field, QueryOperatorType operatorType, int value) { Field = field; @@ -36,14 +36,17 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, DateTime va { Field = field; OperatorType = operatorType; - Value = value.ToStreamDateString(); + // Store the raw DateTime so callers can pick the wire format at serialization time + // (different Stream endpoints accept different RFC 3339 sub-forms - see StreamDateFormat). + Value = value; } public FieldFilterRule(string field, QueryOperatorType operatorType, DateTimeOffset value) { Field = field; OperatorType = operatorType; - Value = value.ToStreamDateString(); + // See note above about deferred date formatting. + Value = value; } public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) @@ -52,32 +55,100 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable OperatorType = operatorType; Value = value.ToArray(); } - + public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) { Field = field; OperatorType = operatorType; - Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); + // See note above about deferred date formatting. + Value = value.ToArray(); } - + public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) { Field = field; OperatorType = operatorType; - Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); + // See note above about deferred date formatting. + Value = value.ToArray(); } + /// + /// Returns the filter entry using the default endpoint-portable date form + /// (). Callers targeting POST /search's + /// message_filter_conditions must use the format-aware overload + /// () with + /// . + /// //StreamTodo: research how to reduce allocation here public KeyValuePair GenerateFilterEntry() + => GenerateFilterEntry(StreamDateFormat.UtcOffset); + + /// + /// Returns the filter entry, formatting any date values using . + /// Non-date values are passed through untouched. + /// + internal KeyValuePair GenerateFilterEntry(StreamDateFormat dateFormat) => new KeyValuePair ( Field, new Dictionary { { - OperatorType.ToOperatorKeyword(), Value + OperatorType.ToOperatorKeyword(), FormatValueForWire(Value, dateFormat) } } ); - + + private static object FormatValueForWire(object value, StreamDateFormat dateFormat) + { + if (value is DateTime dt) + { + return dt.ToStreamDateString(dateFormat); + } + + if (value is DateTimeOffset dto) + { + return dto.ToStreamDateString(dateFormat); + } + + if (value is DateTime[] dts) + { + return dts.Select(d => d.ToStreamDateString(dateFormat)).ToArray(); + } + + if (value is DateTimeOffset[] dtos) + { + return dtos.Select(d => d.ToStreamDateString(dateFormat)).ToArray(); + } + + return value; + } + } + + /// + /// Internal helpers for serializing instances to the wire + /// dictionary with an explicit . + /// + /// + /// The public contract is intentionally + /// parameterless to avoid breaking external implementations. SDK-internal call sites that + /// need the (Z) form - currently only + /// POST /search's message_filter_conditions / filter_conditions - go + /// through this helper. Anything implementing that isn't the + /// SDK's own transparently falls back to the parameterless + /// path (i.e. ). + /// + /// + internal static class FieldFilterRuleExtensions + { + internal static KeyValuePair GenerateFilterEntry(this IFieldFilterRule rule, + StreamDateFormat dateFormat) + { + if (rule is FieldFilterRule concrete) + { + return concrete.GenerateFilterEntry(dateFormat); + } + + return rule.GenerateFilterEntry(); + } } -} \ No newline at end of file +} diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index 9d1f3b3d..55733708 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -5,6 +5,7 @@ using StreamChat.Core.QueryBuilders.Filters; using StreamChat.Core.QueryBuilders.Sort; using StreamChat.Core.StatefulModels; +using StreamChat.Libs.Utils; namespace StreamChat.Core.Requests { @@ -174,13 +175,18 @@ public sealed class StreamSearchMessagesRequest : ISavableTo.SaveToDto() { + // POST /search rejects the "+00:00" offset form on date values inside + // message_filter_conditions / filter_conditions with + // "field \"created_at\" expects type date" (HTTP 400, code 4). It only accepts + // the canonical "Z" UTC form, so opt into StreamDateFormat.Utc here. This is the + // opposite of every other endpoint, which crashes (HTTP 500) on the "Z" form. return new SearchRequestInternalDTO { FilterConditions = ChannelFilter? - .Select(_ => _.GenerateFilterEntry()) + .Select(_ => _.GenerateFilterEntry(StreamDateFormat.Utc)) .ToDictionary(x => x.Key, x => x.Value), MessageFilterConditions = MessageFilter? - .Select(_ => _.GenerateFilterEntry()) + .Select(_ => _.GenerateFilterEntry(StreamDateFormat.Utc)) .ToDictionary(x => x.Key, x => x.Value), Query = Query, Limit = Limit, diff --git a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs index b18f4015..6f34c6d9 100644 --- a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs +++ b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs @@ -7,29 +7,82 @@ namespace StreamChat.Libs.Utils { /// - /// Formats / values in the canonical - /// Stream API format: yyyy-MM-ddTHH:mm:ss.fffZ (UTC, millisecond precision, literal "Z"). + /// Wire format choice for dates sent to the Stream API. /// - /// This matches the format used by all other Stream SDKs (see - /// StreamDateFormatter in stream-chat-android) and is the only form accepted by every - /// Stream endpoint. In particular, the /search endpoint's message_filter_conditions - /// rejects the numeric-offset form (+00:00) with - /// "field "created_at" expects type date", so any date sent to the API must go through - /// this formatter to stay portable across endpoints. + /// The two forms are semantically identical (both encode UTC, RFC 3339), but individual + /// Stream endpoints accept only one of them today: + /// + /// + /// + /// (yyyy-MM-ddTHH:mm:ss.fff+00:00) - required by + /// POST /channels filter_conditions. Sending the Z form causes the + /// server to return HTTP 500 with an empty error message. + /// + /// + /// + /// + /// (yyyy-MM-ddTHH:mm:ss.fffZ) - required by POST /search + /// message_filter_conditions. Sending the offset form is rejected with + /// "field \"created_at\" expects type date" (HTTP 400, code 4). + /// + /// + /// + /// + internal enum StreamDateFormat + { + /// + /// Numeric-offset UTC form: yyyy-MM-ddTHH:mm:ss.fff+00:00. + /// Used by filter_conditions on most endpoints (channels, users, threads, polls). + /// + UtcOffset, + + /// + /// Canonical Zulu UTC form: yyyy-MM-ddTHH:mm:ss.fffZ. + /// Required by message_filter_conditions on POST /search. Matches the + /// format used by other Stream SDKs (e.g. StreamDateFormatter in stream-chat-android). + /// + Utc, + } + + /// + /// Formats / values for the Stream API. + /// + /// + /// Different Stream endpoints disagree on the acceptable RFC 3339 sub-form, so callers must + /// pass an explicit when they know what the target endpoint + /// expects. The parameterless overloads default to , + /// which is the form accepted by every endpoint except POST /search's + /// message_filter_conditions; that one path must opt into + /// . + /// + /// + /// See for the endpoint-by-endpoint compatibility matrix. /// internal static class StreamDateFormatter { - // Equivalent to Java's "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" used by the Android SDK. - private const string DateFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + // "yyyy-MM-ddTHH:mm:ss.fff+00:00" + private const string UtcOffsetFormat = "yyyy-MM-dd'T'HH:mm:ss.fffzzz"; + + // "yyyy-MM-ddTHH:mm:ss.fffZ" - equivalent to Java's "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" used by the Android SDK. + private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; /// - /// Formats in the canonical Stream API format. + /// Formats using the default endpoint-portable form + /// (). Use the overload taking an explicit + /// when sending to POST /search's + /// message_filter_conditions, which only accepts . + /// + internal static string ToStreamDateString(this DateTime dateTime) + => dateTime.ToStreamDateString(StreamDateFormat.UtcOffset); + + /// + /// Formats in the requested Stream API form. /// The value is normalised to UTC before formatting: /// is used as-is, is /// converted via , and /// is assumed to already be UTC. /// - internal static string ToStreamDateString(this DateTime dateTime) + internal static string ToStreamDateString(this DateTime dateTime, StreamDateFormat format) { DateTime utc; switch (dateTime.Kind) @@ -45,15 +98,39 @@ internal static string ToStreamDateString(this DateTime dateTime) break; } - return utc.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + return utc.ToString(GetPattern(format), DateTimeFormatInfo.InvariantInfo); } /// - /// Formats in the canonical Stream API format. - /// The value is converted to UTC before formatting, so the wire output always ends in - /// Z regardless of the source offset. + /// Formats using the default endpoint-portable form + /// (). Use the overload taking an explicit + /// when sending to POST /search's + /// message_filter_conditions, which only accepts . /// internal static string ToStreamDateString(this DateTimeOffset dateTimeOffset) - => dateTimeOffset.UtcDateTime.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + => dateTimeOffset.ToStreamDateString(StreamDateFormat.UtcOffset); + + /// + /// Formats in the requested Stream API form. + /// The value is converted to UTC before formatting; under + /// the wire output therefore always ends in + /// +00:00, and under it ends in Z, + /// regardless of the source offset. + /// + internal static string ToStreamDateString(this DateTimeOffset dateTimeOffset, StreamDateFormat format) + => dateTimeOffset.UtcDateTime.ToString(GetPattern(format), DateTimeFormatInfo.InvariantInfo); + + private static string GetPattern(StreamDateFormat format) + { + switch (format) + { + case StreamDateFormat.UtcOffset: + return UtcOffsetFormat; + case StreamDateFormat.Utc: + return UtcFormat; + default: + throw new ArgumentOutOfRangeException(nameof(format), format, null); + } + } } } From 60f2bef83dee992ba4e79fe223e93ff740f4993b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 27 May 2026 12:12:51 +0200 Subject: [PATCH 10/19] rewrite test for clarity + extend timeout --- .../Tests/StatefulClient/ThreadsTests.cs | 84 ++++++++++++------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs index 53f21095..97aad7a8 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs @@ -196,7 +196,8 @@ private async Task When_load_replies_paginated_three_pages_expect_all_replies_un "Page 2 must come before page 1 (newest)"); } - private static void AssertOrderedAscendingByCreatedAt(System.Collections.Generic.IReadOnlyList messages) + private static void AssertOrderedAscendingByCreatedAt( + System.Collections.Generic.IReadOnlyList messages) { for (var i = 1; i < messages.Count; i++) { @@ -304,7 +305,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest var thread = await Client.GetThreadAsync(parent.Id, replyLimit: 5, participantLimit: 5); await WaitWhileTrueAsync(() => (thread.ParticipantCount ?? 0) == 0, - description: "thread.ParticipantCount to become non-zero after GetThreadAsync (participant-count preservation)"); + description: + "thread.ParticipantCount to become non-zero after GetThreadAsync (participant-count preservation)"); var participantsBefore = thread.ParticipantCount; var activeParticipantsBefore = thread.ActiveParticipantCount; @@ -341,7 +343,8 @@ await WaitWhileTrueAsync(() => (thread.ParticipantCount ?? 0) == 0, // happened (the REST response also carries the new title, but the WS event is // what gives the bug an opportunity to overwrite). await WaitWhileTrueAsync(() => !sawTitleUpdate || observations.Count < 2, - description: "title change to propagate via WS thread.updated and produce >=2 Updated invocations (participant-count preservation)"); + description: + "title change to propagate via WS thread.updated and produce >=2 Updated invocations (participant-count preservation)"); } finally { @@ -398,7 +401,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest var thread = await Client.GetThreadAsync(parent.Id, replyLimit: 5, participantLimit: 5); await WaitWhileTrueAsync(() => thread.CreatedAt == default || (thread.LastMessageAt ?? default) == default, - description: "thread.CreatedAt and LastMessageAt to be populated after GetThreadAsync (timestamp preservation)"); + description: + "thread.CreatedAt and LastMessageAt to be populated after GetThreadAsync (timestamp preservation)"); var createdAtBefore = thread.CreatedAt; var updatedAtBefore = thread.UpdatedAt; @@ -437,7 +441,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest }); await WaitWhileTrueAsync(() => !sawTitleUpdate || observations.Count < 2, - description: "title change to propagate via WS thread.updated and produce >=2 Updated invocations (timestamp preservation)"); + description: + "title change to propagate via WS thread.updated and produce >=2 Updated invocations (timestamp preservation)"); } finally { @@ -508,7 +513,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest var thread = await Client.GetThreadAsync(parent.Id, replyLimit: 5, participantLimit: 5); await WaitWhileTrueAsync(() => (thread.ParticipantCount ?? 0) == 0, - description: "thread.ParticipantCount to become non-zero after GetThreadAsync (mark-read preservation)"); + description: + "thread.ParticipantCount to become non-zero after GetThreadAsync (mark-read preservation)"); var participantsBefore = thread.ParticipantCount; var activeParticipantsBefore = thread.ActiveParticipantCount; @@ -599,7 +605,8 @@ await WaitWhileTrueAsync(() => !readSeen, maxSeconds: 5, /// [UnityTest] public IEnumerator When_notification_mark_read_event_received_expect_local_user_unread_count_cleared() - => ConnectAndExecute(When_notification_mark_read_event_received_expect_local_user_unread_count_cleared_Async); + => ConnectAndExecute( + When_notification_mark_read_event_received_expect_local_user_unread_count_cleared_Async); private async Task When_notification_mark_read_event_received_expect_local_user_unread_count_cleared_Async() { @@ -772,7 +779,8 @@ await otherClientChannel.SendNewMessageAsync(new StreamSendMessageRequest return thread.Read.FirstOrDefault(r => r.User != null && r.User.Id == localUserId); }, r => r != null && r.UnreadMessages > 0, - description: "local user's thread.Read entry to materialize with UnreadMessages > 0 (upsert-reply setup)"); + description: + "local user's thread.Read entry to materialize with UnreadMessages > 0 (upsert-reply setup)"); Assert.IsTrue( thread.ThreadParticipants.Any(p => (p.User?.Id ?? p.UserId) == otherUserId), @@ -830,9 +838,11 @@ await WaitWhileTrueAsync(() => !replyReceived, /// [UnityTest] public IEnumerator When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments() - => ConnectAndExecute(When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async); + => ConnectAndExecute( + When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async); - private async Task When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async() + private async Task + When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async() { var otherClient = await GetConnectedOtherClientAsync(); @@ -852,7 +862,8 @@ private async Task When_watcher_receives_message_new_for_thread_reply_expect_par var localParent = await TryAsync( () => Task.FromResult(channel.Messages.SingleOrDefault(m => m.Id == otherParent.Id)), m => m != null, - description: "local watcher channel.Messages to contain the otherClient parent (watcher reply-count regression)"); + description: + "local watcher channel.Messages to contain the otherClient parent (watcher reply-count regression)"); var replyCountBefore = localParent.ReplyCount ?? 0; @@ -982,9 +993,11 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest /// [UnityTest] public IEnumerator When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate() - => ConnectAndExecute(When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async); + => ConnectAndExecute( + When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async); - private async Task When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async() + private async Task + When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async() { var otherClient = await GetConnectedOtherClientAsync(); @@ -1024,12 +1037,14 @@ await TryAsync( Filter = new IFieldFilterRule[] { ThreadFilter.ChannelCid.EqualsTo(channel) }, }), r => r != null && r.Threads != null && r.Threads.Any(t => t.ParentMessageId == parent.Id), - description: "QueryThreadsAsync to return the freshly-created thread (ThreadTracked-on-watch setup)"); + description: + "QueryThreadsAsync to return the freshly-created thread (ThreadTracked-on-watch setup)"); var otherClientChannel = await otherClient.GetOrCreateChannelWithIdAsync(channel.Type, channel.Id); await WaitWhileTrueAsync(() => trackedThreads.All(t => t.ParentMessageId != parent.Id), - description: "otherClient.ThreadTracked to fire for the thread carried by the channel watch response"); + description: + "otherClient.ThreadTracked to fire for the thread carried by the channel watch response"); var tracked = trackedThreads.First(t => t.ParentMessageId == parent.Id); @@ -1084,10 +1099,13 @@ await WaitWhileTrueAsync(() => tracked.Title != newTitle, /// documented on ICacheRepository.Tracked. /// [UnityTest] - public IEnumerator When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state() - => ConnectAndExecute(When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async); + public IEnumerator + When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state() + => ConnectAndExecute( + When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async); - private async Task When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async() + private async Task + When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async() { var channel = await CreateUniqueTempChannelAsync(); var parent = await channel.SendNewMessageAsync("thread parent for ThreadTracked-once test"); @@ -1120,6 +1138,7 @@ void OnTracked(IStreamThread t) firstEmissionChannelCidAtRaise = t.ChannelCid; } } + Client.ThreadTracked += OnTracked; try @@ -1131,7 +1150,8 @@ void OnTracked(IStreamThread t) Filter = new IFieldFilterRule[] { ThreadFilter.ChannelCid.EqualsTo(channel) }, }), r => r != null && r.Threads != null && r.Threads.Any(t => t.ParentMessageId == parent.Id), - description: "first QueryThreadsAsync to return the newly-created thread (ThreadTracked-once setup)"); + description: + "first QueryThreadsAsync to return the newly-created thread (ThreadTracked-once setup)"); await WaitWhileTrueAsync(() => emissionCount == 0, description: "ThreadTracked to fire for the first QueryThreadsAsync emission"); @@ -1197,9 +1217,11 @@ await TryAsync( Filter = new IFieldFilterRule[] { ThreadFilter.ChannelCid.EqualsTo(channel) }, }), r => r != null && r.Threads != null && r.Threads.Any(t => t.ParentMessageId == parent.Id), - description: "QueryThreadsAsync to return the thread before hard-deleting its parent (ThreadUntracked setup)"); + description: + "QueryThreadsAsync to return the thread before hard-deleting its parent (ThreadUntracked setup)"); IStreamThread untracked = null; + void OnUntracked(IStreamThread t) { if (t.ParentMessageId == parent.Id) @@ -1207,6 +1229,7 @@ void OnUntracked(IStreamThread t) untracked = t; } } + Client.ThreadUntracked += OnUntracked; try @@ -1254,7 +1277,8 @@ await WaitWhileTrueAsync(() => untracked == null, /// [UnityTest] public IEnumerator When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client() - => ConnectAndExecute(When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client_Async); + => ConnectAndExecute( + When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client_Async); private async Task When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client_Async() { @@ -1272,6 +1296,7 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest // No pre-watch here - we want the notification.added_to_channel handler to take // the wasCreated == true branch and exercise the previously-crashing fetch. IStreamChannel addedChannel = null; + void OnAddedToChannelAsMember(IStreamChannel ch, IStreamChannelMember _) { if (ch.Cid == channel.Cid) @@ -1279,6 +1304,7 @@ void OnAddedToChannelAsMember(IStreamChannel ch, IStreamChannelMember _) addedChannel = ch; } } + otherClient.AddedToChannelAsMember += OnAddedToChannelAsMember; try @@ -1286,7 +1312,8 @@ void OnAddedToChannelAsMember(IStreamChannel ch, IStreamChannelMember _) await channel.AddMembersAsync(new[] { otherClient.LocalUserData.User }); await WaitWhileTrueAsync(() => addedChannel == null, maxSeconds: 30, - description: "otherClient.AddedToChannelAsMember to fire after AddMembersAsync (channel-with-thread watch regression)"); + description: + "otherClient.AddedToChannelAsMember to fire after AddMembersAsync (channel-with-thread watch regression)"); } finally { @@ -1362,7 +1389,8 @@ await WaitWhileTrueAsync(() => !thread.LatestReplies.Any(r => r.Id == reply.Id), /// [UnityTest] public IEnumerator When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages() - => ConnectAndExecute(When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages_Async); + => ConnectAndExecute( + When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages_Async); private async Task When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages_Async() { @@ -1387,10 +1415,10 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest }); await WaitWhileTrueAsync( - () => !thread.LatestReplies.Any(r => r.Id == reply.Id) - || !channel.Messages.Any(m => m.Id == reply.Id), - maxSeconds: 15, - description: "reply with ShowInChannel=true to appear in BOTH thread.LatestReplies and channel.Messages"); + () => thread.LatestReplies.All(r => r.Id != reply.Id) || channel.Messages.All(m => m.Id != reply.Id), + maxSeconds: 30, + description: + "reply with ShowInChannel=true to appear in BOTH thread.LatestReplies and channel.Messages"); Assert.IsTrue(thread.LatestReplies.Any(r => r.Id == reply.Id), "Reply with ShowInChannel=true must appear in thread.LatestReplies."); @@ -1399,4 +1427,4 @@ await WaitWhileTrueAsync( } } } -#endif +#endif \ No newline at end of file From cb384895846a9f3c004574852dfa343911feb143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 28 May 2026 11:52:35 +0200 Subject: [PATCH 11/19] Fix flaky When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages, it was implicitly relying on order of WS events --- .../StreamChat/Core/StatefulModels/StreamChannel.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs index 8258fa32..1bb4465b 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs @@ -879,7 +879,7 @@ private void AssertCid(string cid) private bool InternalAppendOrUpdateMessage(MessageInternalDTO dto, out StreamMessage streamMessage) { - streamMessage = Cache.TryCreateOrUpdate(dto, out var wasCreated); + streamMessage = Cache.TryCreateOrUpdate(dto, out _); // A message belongs in the channel timeline iff it's a top-level message, // or a thread reply explicitly opted into "also show in channel". @@ -892,8 +892,9 @@ private bool InternalAppendOrUpdateMessage(MessageInternalDTO dto, out StreamMes return false; } - var isNewMessage = wasCreated && !_messages.ContainsNoAlloc(streamMessage); - if (!isNewMessage) + // Idempotent: REST, message.new, and notification.thread_message_new can each + // populate the cache first, so the only safe insert gate is "not already here". + if (_messages.ContainsNoAlloc(streamMessage)) { return true; } From 5cfa9f6dcecfd7e8f8e5956ce9cd7ee1637ca8b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 28 May 2026 12:37:14 +0200 Subject: [PATCH 12/19] Don't serialize MessageFilterConditions if null or empty -> we add this because the server disallows having both "query" and "message_filter_conditions " --- .../Requests/StreamSearchMessagesRequest.cs | 15 ++++-- .../StatefulClient/SearchMessagesTests.cs | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index 55733708..371a6fc2 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -185,9 +185,7 @@ SearchRequestInternalDTO ISavableTo.SaveToDto() FilterConditions = ChannelFilter? .Select(_ => _.GenerateFilterEntry(StreamDateFormat.Utc)) .ToDictionary(x => x.Key, x => x.Value), - MessageFilterConditions = MessageFilter? - .Select(_ => _.GenerateFilterEntry(StreamDateFormat.Utc)) - .ToDictionary(x => x.Key, x => x.Value), + MessageFilterConditions = ToMessageFilterConditionsOrNullIfEmpty(MessageFilter), Query = Query, Limit = Limit, Offset = Offset, @@ -195,5 +193,16 @@ SearchRequestInternalDTO ISavableTo.SaveToDto() Sort = Sort?.ToSortParamRequestList(), }; } + + private static Dictionary ToMessageFilterConditionsOrNullIfEmpty( + IEnumerable rules) + { + var conditions = rules? + .Where(_ => _ != null) + .Select(_ => _.GenerateFilterEntry(StreamDateFormat.Utc)) + .ToDictionary(x => x.Key, x => x.Value); + + return conditions != null && conditions.Count > 0 ? conditions : null; + } } } diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index 28a4dc1a..b5bcabb1 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using StreamChat.Core.InternalDTO.Requests; using StreamChat.Core.LowLevelClient; using StreamChat.Core.QueryBuilders.Filters; using StreamChat.Core.QueryBuilders.Filters.Channels; @@ -13,6 +14,7 @@ using StreamChat.Core.QueryBuilders.Sort; using StreamChat.Core.Requests; using StreamChat.Core.StatefulModels; +using StreamChat.Libs.Serialization; using UnityEngine.TestTools; namespace StreamChat.Tests.StatefulClient @@ -171,6 +173,29 @@ public void When_request_save_to_dto_then_channel_and_message_filters_are_separa Assert.AreEqual(0, dto.Offset); } + [TestCase(0, TestName = "empty array")] + [TestCase(1, TestName = "array of null rules")] + public void When_request_with_query_and_no_effective_message_filter_then_wire_payload_omits_message_filter_conditions( + int nullRuleCount) + { + var request = new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo("messaging:abc"), + }, + MessageFilter = new IFieldFilterRule[nullRuleCount], + Query = "hello", + }; + + var dto = ((ISavableTo)request).SaveToDto(); + Assert.IsNull(dto.MessageFilterConditions); + + var json = new NewtonsoftJsonSerializer().Serialize(dto); + Assert.IsFalse(json.Contains("message_filter_conditions"), + "Got payload: " + json); + } + // --------------------------------------------------------------------- // Client-side validation tests // --------------------------------------------------------------------- @@ -377,6 +402,30 @@ private async Task When_search_by_mentioned_user_id_expect_only_messages_mention Assert.AreEqual(channel.Cid, hit.Channel.Cid); } + [UnityTest] + public IEnumerator When_search_with_query_and_empty_message_filter_expect_success() + => ConnectAndExecute(When_search_with_query_and_empty_message_filter_expect_success_Async); + + private async Task When_search_with_query_and_empty_message_filter_expect_success_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "emptyfilter-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sent = await channel.SendNewMessageAsync(token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[0], + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == sent.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == sent.Id)); + } + [UnityTest] public IEnumerator When_search_with_query_text_expect_matching_message_returned() => ConnectAndExecute(When_search_with_query_text_expect_matching_message_returned_Async); From 610ef6fb144f8d8ec19db3261cbc308c71e1f342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 28 May 2026 15:33:48 +0200 Subject: [PATCH 13/19] Refactor WatchedChannels to only contain watched channels. Before search was possible, all channels were watched by default. It is now possible to fetch channels without watching and the current WatchedChannels could contain a mix. --- .../StreamChat/Core/IStreamChatClient.cs | 11 +- .../Core/StatefulModels/IStreamChannel.cs | 21 +++ .../Core/StatefulModels/StreamChannel.cs | 14 +- .../StreamChat/Core/StreamChatClient.cs | 77 ++++++++++- .../Tests/StatefulClient/ChannelsTests.cs | 91 +++++++++++++ .../StatefulClient/SearchMessagesTests.cs | 122 ++++++++++++++++++ 6 files changed, 325 insertions(+), 11 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs index 1f4a61a9..6c322569 100644 --- a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs @@ -119,8 +119,15 @@ public interface IStreamChatClient : IDisposable, IStreamChatClientEventsListene IStreamLocalUserData LocalUserData { get; } /// - /// Watched channels receive updates on all users activity like new messages, reactions, etc. - /// Use and to watch channels + /// Channels currently watched by the SDK. Watched channels receive realtime updates + /// (new messages, reactions, member changes, etc.). + /// + /// + /// Start watching with or + /// ; stop with . + /// Channels returned by other endpoints may not be watched - check + /// on a specific instance to know its state. + /// /// IReadOnlyList WatchedChannels { get; } diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs index b808107c..1ccbeaae 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs @@ -272,6 +272,27 @@ public interface IStreamChannel : IStreamStatefulModel /// bool IsDirectMessage { get; } + /// + /// Whether this channel is currently watched and receiving realtime events + /// (new messages, reactions, threads, member changes, etc.). + /// + /// + /// Channels returned by and + /// are automatically + /// watched. lets you opt in + /// per call via ; + /// exposes a similar + /// flag. Use + /// to stop watching a channel. + /// + /// + /// + /// When this is false the channel will NOT fire events like + /// until it is watched again. + /// + /// + bool IsWatched { get; } + /// /// Basic send message method. If you want to set additional parameters use the overload /// diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs index 1bb4465b..d70546d1 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs @@ -181,6 +181,8 @@ internal set public bool IsDirectMessage => Members.Count == 2 && Members.Any(m => m.User == Client.LocalUserData.User); + public bool IsWatched { get; internal set; } + public Task SendNewMessageAsync(string message) => SendNewMessageAsync(new StreamSendMessageRequest { @@ -618,11 +620,17 @@ public async Task TruncateAsync(DateTimeOffset? truncatedAt = default, string sy Cache.TryCreateOrUpdate(response.Channel); } - //StreamTodo: write test and check Client.WatchedChannels - public Task StopWatchingAsync() - => LowLevelClient.InternalChannelApi.StopWatchingChannelAsync(Type, Id, + public async Task StopWatchingAsync() + { + await LowLevelClient.InternalChannelApi.StopWatchingChannelAsync(Type, Id, new ChannelStopWatchingRequestInternalDTO()); + // Bookkeeping lives on the client (it owns the WatchedChannels list). + // The instance stays in cache so existing references remain valid; it + // just stops surfacing events. + Client.InternalMarkChannelUnwatched(this); + } + public async Task FreezeAsync() { var response = await LowLevelClient.InternalChannelApi.UpdateChannelPartialAsync(Type, Id, diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index cd109fa9..2aa21372 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -101,7 +101,7 @@ public sealed class StreamChatClient : IStreamChatClient private StreamLocalUserData _localUserData; - public IReadOnlyList WatchedChannels => _cache.Channels.AllItems; + public IReadOnlyList WatchedChannels => _watchedChannels; public double? NextReconnectTime => InternalLowLevelClient.NextReconnectTime; @@ -274,7 +274,9 @@ public async Task GetOrCreateChannelWithMembersAsync(ChannelType var channelResponseDto = await InternalLowLevelClient.InternalChannelApi.GetOrCreateChannelAsync(channelType, requestBodyDto); - return _cache.TryCreateOrUpdate(channelResponseDto); + var channel = _cache.TryCreateOrUpdate(channelResponseDto); + MarkChannelWatched(channel); + return channel; } public async Task> QueryChannelsAsync(IEnumerable filters = null, @@ -313,7 +315,9 @@ var channelsResponseDto var result = new List(); foreach (var channelDto in channelsResponseDto.Channels) { - result.Add(_cache.TryCreateOrUpdate(channelDto)); + var channel = _cache.TryCreateOrUpdate(channelDto); + MarkChannelWatched(channel); + result.Add(channel); } return result; @@ -357,7 +361,9 @@ var channelsResponseDto var result = new List(); foreach (var channelDto in channelsResponseDto.Channels) { - result.Add(_cache.TryCreateOrUpdate(channelDto)); + var channel = _cache.TryCreateOrUpdate(channelDto); + MarkChannelWatched(channel); + result.Add(channel); } return result; @@ -471,7 +477,16 @@ public async Task GetThreadAsync(string parentMessageId, memberLimit: memberLimit, watch: watch); - return _cache.TryCreateOrUpdate(response.Thread); + var thread = _cache.TryCreateOrUpdate(response.Thread); + + // The /threads response always embeds the parent channel; only flip IsWatched + // when the caller actually requested watch (preserve any prior IsWatched=true). + if (watch) + { + MarkChannelWatched(thread?.Channel as StreamChannel); + } + + return thread; } public async Task QueryThreadsAsync(StreamQueryThreadsRequest request) @@ -489,6 +504,11 @@ public async Task QueryThreadsAsync(StreamQueryThrea var thread = _cache.TryCreateOrUpdate(threadDto); if (thread != null) { + // Same as GetThreadAsync: only mark watched when Watch=true was requested. + if (request.Watch) + { + MarkChannelWatched(thread.Channel as StreamChannel); + } threads.Add(thread); } } @@ -532,6 +552,9 @@ public async Task SearchMessagesAsync( IStreamChannel channel = null; if (searchMsgDto.Channel != null) { + // Cache for identity reuse only - /search does NOT start a server-side + // watch. Newly-cached channels stay IsWatched=false; already-watched + // ones keep their flag. WatchResultChannels=true upgrades below. channel = _cache.TryCreateOrUpdate(searchMsgDto.Channel); if (channel != null && !distinctChannels.ContainsKey(channel.Cid)) { @@ -809,6 +832,11 @@ public void Dispose() _cache.Threads.Untracked -= OnThreadLeftCache; } + if (_cache?.Channels != null) + { + _cache.Channels.Untracked -= OnChannelLeftCache; + } + _isDisposed = true; Disposed?.Invoke(); } @@ -860,7 +888,12 @@ internal async Task InternalGetOrCreateChannelWithIdAsync(Channe var channelResponseDto = await InternalLowLevelClient.InternalChannelApi.GetOrCreateChannelAsync( channelType, channelId, requestBodyDto); - return _cache.TryCreateOrUpdate(channelResponseDto); + var channel = _cache.TryCreateOrUpdate(channelResponseDto); + if (watch) + { + MarkChannelWatched(channel); + } + return channel; } internal IStreamLocalUserData UpdateLocalUser(OwnUserInternalDTO ownUserInternalDto) @@ -906,6 +939,7 @@ internal Task RefreshChannelState(string cid) private readonly ITimeService _timeService; private readonly ICache _cache; private readonly StreamPollsApi _pollsApi; + private readonly List _watchedChannels = new List(); private TaskCompletionSource _connectUserTaskSource; private CancellationToken _connectUserCancellationToken; @@ -946,6 +980,7 @@ private StreamChatClient(IWebsocketClient websocketClient, IHttpClient httpClien _cache.Threads.Tracked += OnThreadEnteredCache; _cache.Threads.Untracked += OnThreadLeftCache; + _cache.Channels.Untracked += OnChannelLeftCache; SubscribeTo(InternalLowLevelClient); } @@ -961,6 +996,36 @@ private void InternalDeleteChannel(StreamChannel channel) ChannelDeleted?.Invoke(channel.Cid, channel.Id, channel.Type); } + // Flip IsWatched=true and add to _watchedChannels. Call from every path that issued + // Watch=true to the server. Channels that land in the cache via non-watching paths + // (search hits, threads with Watch=false, ban-info / mute payloads) stay IsWatched=false. + // Idempotent: a no-op when the channel is already watched. + private void MarkChannelWatched(StreamChannel channel) + { + if (channel == null || channel.IsWatched) + { + return; + } + + channel.IsWatched = true; + _watchedChannels.Add(channel); + } + + // Counterpart to MarkChannelWatched. Called from StreamChannel.StopWatchingAsync + // after the server confirms the unwatch. Idempotent. + internal void InternalMarkChannelUnwatched(StreamChannel channel) + { + if (channel == null || !channel.IsWatched) + { + return; + } + + channel.IsWatched = false; + _watchedChannels.Remove(channel); + } + + private void OnChannelLeftCache(StreamChannel channel) => _watchedChannels.Remove(channel); + private void TryCancelWaitingForUserConnection() { var isConnectTaskRunning = _connectUserTaskSource?.Task != null && !_connectUserTaskSource.Task.IsCompleted; diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelsTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelsTests.cs index b15de59e..f5e3481e 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelsTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelsTests.cs @@ -686,6 +686,97 @@ private async Task When_setting_channel_partition_ttl_and_size_expect_no_errors_ Assert.AreEqual("24h", channel.Config.PartitionTtl); } + + // --------------------------------------------------------------------- + // IsWatched / WatchedChannels semantics + // --------------------------------------------------------------------- + + /// + /// Channels obtained via + /// (which always uses watch=true) must report IsWatched == true and appear + /// in . + /// + [UnityTest] + public IEnumerator When_get_or_create_channel_expect_is_watched_and_in_watched_channels() + => ConnectAndExecute(When_get_or_create_channel_expect_is_watched_and_in_watched_channels_Async); + + private async Task When_get_or_create_channel_expect_is_watched_and_in_watched_channels_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + + Assert.IsTrue(channel.IsWatched, "GetOrCreateChannelWithIdAsync must produce a watched channel."); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "Watched channel must appear in WatchedChannels."); + } + + /// + /// Channels surfaced by must be + /// IsWatched == true (the SDK always sets watch=true in the request body). + /// + [UnityTest] + public IEnumerator When_query_channels_expect_results_are_watched() + => ConnectAndExecute(When_query_channels_expect_results_are_watched_Async); + + private async Task When_query_channels_expect_results_are_watched_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + + var results = await Client.QueryChannelsAsync(new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }); + + var fromQuery = results.FirstOrDefault(c => c.Cid == channel.Cid); + Assert.IsNotNull(fromQuery, "Channel should be returned by QueryChannelsAsync."); + Assert.IsTrue(fromQuery.IsWatched, "QueryChannelsAsync results must be watched."); + Assert.AreSame(channel, fromQuery, "Cache identity preserved across QueryChannelsAsync."); + } + + /// + /// must flip + /// to false and remove the channel from + /// . + /// + [UnityTest] + public IEnumerator When_stop_watching_expect_is_watched_false_and_removed_from_watched_channels() + => ConnectAndExecute(When_stop_watching_expect_is_watched_false_and_removed_from_watched_channels_Async); + + private async Task When_stop_watching_expect_is_watched_false_and_removed_from_watched_channels_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + + Assert.IsTrue(channel.IsWatched, "Test precondition: channel must be watched after creation."); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid)); + + await channel.StopWatchingAsync(); + + Assert.IsFalse(channel.IsWatched, + "After StopWatchingAsync the channel.IsWatched must be false."); + Assert.IsFalse(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "After StopWatchingAsync the channel must NOT appear in WatchedChannels."); + } + + /// + /// After a follow-up + /// must promote the + /// SAME instance back to watched. + /// + [UnityTest] + public IEnumerator When_stop_watching_then_get_or_create_expect_same_instance_watched_again() + => ConnectAndExecute(When_stop_watching_then_get_or_create_expect_same_instance_watched_again_Async); + + private async Task When_stop_watching_then_get_or_create_expect_same_instance_watched_again_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + await channel.StopWatchingAsync(); + Assert.IsFalse(channel.IsWatched); + + var rewatched = await Client.GetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + + Assert.AreSame(channel, rewatched, "Cache identity must survive watch/unwatch transitions."); + Assert.IsTrue(rewatched.IsWatched); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid)); + } } } diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index b5bcabb1..bf94b2f0 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -849,6 +849,128 @@ private async Task When_search_with_watch_result_channels_true_expect_channel_in Assert.IsTrue(response.Results.Any(r => r.Message.Id == msg.Id)); Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), "With WatchResultChannels=true the hit channel must appear in WatchedChannels."); + Assert.IsTrue(response.Results.First(r => r.Message.Id == msg.Id).Channel.IsWatched, + "With WatchResultChannels=true the result Channel.IsWatched must be true."); + } + + /// + /// Verifies the core fix: a search hit's channel that is NOT already watched + /// must NOT pollute when + /// = false. + /// + /// + /// We use a second client to create the channel so the searching client has no + /// prior cache entry for it; otherwise the channel would already be watched on + /// the searching client and the test would be trivially true. + /// + /// + [UnityTest] + public IEnumerator When_search_with_watch_result_channels_false_expect_channel_not_in_watched_channels() + => ConnectAndExecute(When_search_with_watch_result_channels_false_expect_channel_not_in_watched_channels_Async); + + private async Task When_search_with_watch_result_channels_false_expect_channel_not_in_watched_channels_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + + var channel = await CreateUniqueTempChannelAsync(overrideClient: otherClient); + var token = "watchfalse-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + // Searching client has never interacted with this channel, so it must not + // already be watched. + Assert.IsFalse(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "Test precondition: searching client must not be watching the channel."); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + WatchResultChannels = false, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + var hit = response.Results.First(r => r.Message.Id == msg.Id); + + Assert.IsNotNull(hit.Channel, "Hit Channel should still be returned even when not watched."); + Assert.IsFalse(hit.Channel.IsWatched, + "WatchResultChannels=false: hit Channel.IsWatched must be false."); + Assert.IsFalse(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "WatchResultChannels=false: hit channel must NOT appear in WatchedChannels."); + } + + /// + /// If the channel is already watched (e.g. previously surfaced via QueryChannelsAsync + /// or GetOrCreateChannelWithIdAsync), running a search with WatchResultChannels=false + /// must NOT downgrade it to unwatched. The channel keeps receiving WS events. + /// + [UnityTest] + public IEnumerator When_search_with_watch_result_channels_false_for_already_watched_channel_expect_still_watched() + => ConnectAndExecute( + When_search_with_watch_result_channels_false_for_already_watched_channel_expect_still_watched_Async); + + private async Task When_search_with_watch_result_channels_false_for_already_watched_channel_expect_still_watched_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "alreadywatched-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + Assert.IsTrue(channel.IsWatched, "Test precondition: channel created with watch=true should be watched."); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + WatchResultChannels = false, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + var hit = response.Results.First(r => r.Message.Id == msg.Id); + Assert.IsTrue(hit.Channel.IsWatched, + "Search must not downgrade an already-watched channel."); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid)); + } + + /// + /// After SearchMessagesAsync(WatchResultChannels=false), a follow-up + /// GetOrCreateChannelWithIdAsync on the same CID must promote the cached instance + /// to watched (cache identity preserved across the transition). + /// + [UnityTest] + public IEnumerator When_search_unwatched_channel_then_get_or_create_expect_same_instance_now_watched() + => ConnectAndExecute( + When_search_unwatched_channel_then_get_or_create_expect_same_instance_now_watched_Async); + + private async Task When_search_unwatched_channel_then_get_or_create_expect_same_instance_now_watched_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + var channel = await CreateUniqueTempChannelAsync(overrideClient: otherClient); + var token = "promote-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + WatchResultChannels = false, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + var unwatched = response.Results.First(r => r.Message.Id == msg.Id).Channel; + Assert.IsFalse(unwatched.IsWatched); + + var watched = await Client.GetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + + Assert.AreSame(unwatched, watched, + "GetOrCreateChannelWithIdAsync should promote the existing search-cached instance, not create a new one."); + Assert.IsTrue(watched.IsWatched, "After GetOrCreateChannelWithIdAsync the instance should be watched."); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "Promoted channel must now appear in WatchedChannels."); } // --------------------------------------------------------------------- From 08ce9cb9962dea70a8f9ba48fca545c5b2e3022e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 29 May 2026 12:41:18 +0200 Subject: [PATCH 14/19] watch search results by default + add StreamMessage.IsWatched flag + add option to Watch unwatched channel --- .../Requests/StreamSearchMessagesRequest.cs | 32 +++- .../Responses/StreamSearchMessageResult.cs | 38 +++-- .../Core/StatefulModels/IStreamChannel.cs | 34 ++++- .../Core/StatefulModels/IStreamMessage.cs | 25 +++ .../Core/StatefulModels/StreamChannel.cs | 14 ++ .../Core/StatefulModels/StreamMessage.cs | 5 + .../StreamChat/Core/StreamChatClient.cs | 23 ++- .../StatefulClient/SearchMessagesTests.cs | 142 ++++++++++++++++++ 8 files changed, 285 insertions(+), 28 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index 371a6fc2..61280307 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -164,14 +164,32 @@ public sealed class StreamSearchMessagesRequest : ISavableTo instances and their parent /// receive realtime WebSocket updates. /// - /// Default: false. Recommended for a search-results UI - watch the channel only when - /// the user opens one of the hits to avoid mass-watching channels behind the customer's back. - /// When false, hit messages are still cached as , but their - /// parent only receives realtime events once explicitly watched - /// (e.g. via or - /// ). + /// + /// Default: true. This keeps the SDK's "stateful = reactive" contract intact - every + /// / returned here behaves the same + /// as one obtained through or + /// : it fires events, stays in + /// sync with the server, and shows up in . + /// Mirrors the behaviour of MessageSearchSource in the JavaScript SDK. + /// + /// + /// + /// Set to false when a search UI shouldn't subscribe to every result channel up front - + /// e.g. a "search bar" where the user opens one hit at a time. In that mode the result + /// and its parent will not receive + /// realtime updates until the channel is explicitly watched. Call + /// on the result's + /// when the user opens a hit to start receiving updates on that same instance. Use + /// / to check + /// whether a given instance is currently receiving updates. + /// + /// + /// + /// Cost when true: one channel watch round-trip per distinct CID in the result set + /// (parallelised internally). + /// /// - public bool WatchResultChannels { get; set; } + public bool WatchResultChannels { get; set; } = true; SearchRequestInternalDTO ISavableTo.SaveToDto() { diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs index 69de3187..1b1648dc 100644 --- a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs @@ -5,27 +5,41 @@ namespace StreamChat.Core.Responses /// /// A single hit from . /// - /// Both and are stateful, cache-tracked instances: - /// if the same message/channel is already in the cache (because the channel is watched, the - /// message was loaded as a reply, etc.) the same object reference is returned here. + /// + /// and share identity with the rest of the SDK: + /// if you already hold a reference to the same message or channel (e.g. from + /// or any channel's + /// ), the search hit returns that same reference. + /// Changes coming from the server are reflected on the single instance, so updates observed + /// through the search hit are also visible to every other reference to it. + /// + /// + /// + /// By default ( = true) + /// the result is automatically watched, so both and + /// receive realtime updates exactly like objects obtained from + /// . If you opted out of that, neither receives + /// updates until you call on ; see + /// / . + /// /// public sealed class StreamSearchMessageResult { /// - /// The matching message. Updated by realtime events the same way as any other - /// stateful message returned by the SDK. + /// The matching message. Receives realtime updates (reactions, edits, deletions, ...) + /// whenever its parent is watched. See + /// . /// public IStreamMessage Message { get; internal set; } /// - /// The channel the message belongs to. May be the same instance as one in - /// if the channel is already watched. + /// The channel the message belongs to. By default it is watched and appears in + /// , receiving realtime updates. When the + /// request was issued with + /// = false, + /// this channel does not receive updates - call + /// on it to start receiving them on this same instance. /// - /// - /// The channel object is cached but is not automatically watched (no WS subscription) - /// unless is set - /// to true. - /// public IStreamChannel Channel { get; internal set; } } } diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs index 1ccbeaae..6dcb838b 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs @@ -279,16 +279,19 @@ public interface IStreamChannel : IStreamStatefulModel /// /// Channels returned by and /// are automatically - /// watched. lets you opt in - /// per call via ; - /// exposes a similar - /// flag. Use - /// to stop watching a channel. + /// watched. watches result + /// channels by default; pass + /// = false + /// to opt out. exposes a similar + /// flag (default false). + /// Use to start watching and + /// to stop. /// /// /// /// When this is false the channel will NOT fire events like - /// until it is watched again. + /// until it is watched again, and + /// will be false for every message in it. /// /// bool IsWatched { get; } @@ -534,6 +537,25 @@ Task AddMembersAsync(bool? hideHistory = default, StreamMessageRequest optionalM Task TruncateAsync(DateTimeOffset? truncatedAt = default, string systemMessage = "", bool skipPushNotifications = false, bool isHardDelete = false); + /// + /// Start watching this channel so that the local state stays in sync with the server through realtime + /// WebSocket events (new messages, reactions, member changes, typing, etc.). Once watched, the channel + /// appears in and becomes true. + /// + /// + /// Useful when this channel was obtained through a non-watching path - e.g. as a hit on + /// with + /// set to false, or as the + /// parent channel of a thread loaded with set to + /// false. Cache identity is preserved across the upgrade - the same instance becomes reactive. + /// + /// + /// Idempotent: a no-op if the channel is already watched. + /// + /// Counterpart to . + /// + Task WatchAsync(); + /// /// Stop watching this channel meaning you will no longer receive any updates and it will be removed from /// diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs index 850a7fbf..39da0f43 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs @@ -182,6 +182,31 @@ public interface IStreamMessage : IStreamStatefulModel bool IsDeleted { get; } + /// + /// Whether this message will receive realtime updates (reactions, edits, deletions, etc.) + /// from the server. Reactivity is per-channel: a message is "watched" if and only if + /// its parent is currently watched + /// (). + /// + /// + /// Messages obtained through standard paths (, + /// , the channel's own + /// , etc.) are always watched. Messages can be + /// non-watched when surfaced through + /// with set to + /// false, or through threads loaded with + /// set to false. + /// + /// + /// + /// When this is false, events like will not fire on + /// this instance until the parent channel is watched. Promote the channel via + /// to start receiving updates - cache identity + /// is preserved, so this very instance becomes reactive. + /// + /// + bool IsWatched { get; } + /// /// Clears the message text but leaves the rest of the message data like: reactions, replies, attachments unchanged /// If you want to remove the message and all its components completely use the diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs index d70546d1..3af52eaf 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs @@ -620,6 +620,20 @@ public async Task TruncateAsync(DateTimeOffset? truncatedAt = default, string sy Cache.TryCreateOrUpdate(response.Channel); } + public async Task WatchAsync() + { + if (IsWatched) + { + return; + } + + // Delegate to the client so the watched-channel bookkeeping and the cache update + // go through exactly the same path as GetOrCreateChannelWithIdAsync. Cache identity + // is preserved - the client looks up by (type, id), the cache returns this same + // instance, and MarkChannelWatched flips IsWatched on it. + await Client.InternalGetOrCreateChannelWithIdAsync(Type, Id); + } + public async Task StopWatchingAsync() { await LowLevelClient.InternalChannelApi.StopWatchingChannelAsync(Type, Id, diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs index 8e896484..5e5e4382 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs @@ -92,6 +92,11 @@ internal sealed class StreamMessage : StreamStatefulModelBase, public bool IsDeleted => Type == MessageType.Deleted; + public bool IsWatched + => !string.IsNullOrEmpty(Cid) + && Cache.Channels.TryGet(Cid, out var channel) + && channel.IsWatched; + // Apply the REST response to the cache so callers don't have to wait for the // `message.deleted` WS event before observing `DeletedAt` / `IsDeleted` / cleared // text on this very instance. The WS event still fires on watchers (including this diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index 2aa21372..49b7f48f 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -575,13 +575,30 @@ public async Task SearchMessagesAsync( if (request.WatchResultChannels && distinctChannels.Count > 0) { + cancellationToken.ThrowIfCancellationRequested(); + + // Watch all distinct result channels in parallel. Default-true means this fires + // on every search; serial would multiply latency by the number of distinct + // channels in the result set (typically 1-10 for a 30-result page). + // Skip channels that are already watched - WatchAsync is idempotent but the + // extra round-trip is wasteful when there's nothing to upgrade. + var watchTasks = new List(distinctChannels.Count); foreach (var channel in distinctChannels.Values) { - cancellationToken.ThrowIfCancellationRequested(); + if (channel.IsWatched) + { + continue; + } - //StreamTodo: parallelise once cancellation is plumbed; serial keeps load predictable for now. - await InternalGetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + watchTasks.Add(InternalGetOrCreateChannelWithIdAsync(channel.Type, channel.Id)); } + + if (watchTasks.Count > 0) + { + await Task.WhenAll(watchTasks); + } + + cancellationToken.ThrowIfCancellationRequested(); } return new StreamSearchMessagesResponse diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index bf94b2f0..765c5030 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -853,6 +853,148 @@ private async Task When_search_with_watch_result_channels_true_expect_channel_in "With WatchResultChannels=true the result Channel.IsWatched must be true."); } + /// + /// Default behaviour: when + /// is left at its default (true), unfamiliar result channels must be auto-watched + /// and the result message must report = true. + /// + /// Mirrors JS SDK MessageSearchSource.query() which calls queryChannels for + /// any cid not already in client.activeChannels. + /// + [UnityTest] + public IEnumerator When_search_with_default_request_expect_unknown_channel_auto_watched() + => ConnectAndExecute(When_search_with_default_request_expect_unknown_channel_auto_watched_Async); + + private async Task When_search_with_default_request_expect_unknown_channel_auto_watched_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + var channel = await CreateUniqueTempChannelAsync(overrideClient: otherClient); + var token = "default-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + Assert.IsFalse(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "Test precondition: searching client must not be watching the channel."); + + // Note: WatchResultChannels intentionally NOT set - we want to assert the default. + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + var hit = response.Results.First(r => r.Message.Id == msg.Id); + + Assert.IsTrue(hit.Channel.IsWatched, + "Default request: result Channel.IsWatched must be true."); + Assert.IsTrue(hit.Message.IsWatched, + "Default request: result Message.IsWatched must be true (proxies channel watch state)."); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "Default request: hit channel must appear in WatchedChannels."); + } + + /// + /// must report false for messages whose + /// parent channel is not watched (e.g. opt-out search result). + /// + [UnityTest] + public IEnumerator When_search_with_watch_result_channels_false_expect_message_is_watched_false() + => ConnectAndExecute(When_search_with_watch_result_channels_false_expect_message_is_watched_false_Async); + + private async Task When_search_with_watch_result_channels_false_expect_message_is_watched_false_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + var channel = await CreateUniqueTempChannelAsync(overrideClient: otherClient); + var token = "msgwatchfalse-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + Assert.IsFalse(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "Test precondition: searching client must not be watching the channel."); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + WatchResultChannels = false, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + var hit = response.Results.First(r => r.Message.Id == msg.Id); + + Assert.IsFalse(hit.Channel.IsWatched, + "Opt-out: Channel.IsWatched must be false."); + Assert.IsFalse(hit.Message.IsWatched, + "Opt-out: Message.IsWatched must be false (proxies channel watch state)."); + } + + /// + /// Calling on an unwatched cached channel must + /// promote it to watched while preserving cache identity. Verifies the canonical + /// "user clicked the search hit, open the channel" upgrade path. + /// + [UnityTest] + public IEnumerator When_unwatched_search_channel_then_watch_async_expect_same_instance_now_watched() + => ConnectAndExecute(When_unwatched_search_channel_then_watch_async_expect_same_instance_now_watched_Async); + + private async Task When_unwatched_search_channel_then_watch_async_expect_same_instance_now_watched_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + var channel = await CreateUniqueTempChannelAsync(overrideClient: otherClient); + var token = "watchasync-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + WatchResultChannels = false, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + var hit = response.Results.First(r => r.Message.Id == msg.Id); + Assert.IsFalse(hit.Channel.IsWatched); + Assert.IsFalse(hit.Message.IsWatched); + + var watchedReference = hit.Channel; + await hit.Channel.WatchAsync(); + + Assert.AreSame(watchedReference, hit.Channel, + "WatchAsync must preserve cache identity - same instance, just upgraded."); + Assert.IsTrue(hit.Channel.IsWatched, + "After WatchAsync the channel must be watched."); + Assert.IsTrue(hit.Message.IsWatched, + "After WatchAsync the search-hit message must also report IsWatched=true."); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "After WatchAsync the channel must appear in WatchedChannels."); + } + + /// + /// is idempotent - calling it on an already-watched + /// channel must not duplicate WatchedChannels entries and must complete successfully. + /// + [UnityTest] + public IEnumerator When_watch_async_on_already_watched_channel_expect_no_op() + => ConnectAndExecute(When_watch_async_on_already_watched_channel_expect_no_op_Async); + + private async Task When_watch_async_on_already_watched_channel_expect_no_op_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + Assert.IsTrue(channel.IsWatched); + var watchedCountBefore = Client.WatchedChannels.Count(c => c.Cid == channel.Cid); + + await channel.WatchAsync(); + + Assert.IsTrue(channel.IsWatched); + Assert.AreEqual(watchedCountBefore, Client.WatchedChannels.Count(c => c.Cid == channel.Cid), + "Idempotent: re-watching must not double-add to WatchedChannels."); + } + /// /// Verifies the core fix: a search hit's channel that is NOT already watched /// must NOT pollute when From 1b15bd6b62223438f122ecc85e95dd5480475b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 29 May 2026 13:12:35 +0200 Subject: [PATCH 15/19] Revise comments + optimize watching search result channels --- .../Requests/StreamSearchMessagesRequest.cs | 35 +++++------- .../Core/StatefulModels/IStreamMessage.cs | 27 +++++----- .../Core/StatefulModels/StreamChannel.cs | 4 -- .../StreamChat/Core/StreamChatClient.cs | 54 ++++++++++--------- 4 files changed, 57 insertions(+), 63 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index 61280307..f8ac4b8b 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -160,33 +160,26 @@ public sealed class StreamSearchMessagesRequest : ISavableTo - /// Whether the SDK should start watching the channels that appear in the result set so - /// that the returned instances and their parent - /// receive realtime WebSocket updates. + /// Whether the result instances and their parent + /// should receive realtime updates (new reactions, edits, + /// deletions, etc.) after the search completes. /// /// - /// Default: true. This keeps the SDK's "stateful = reactive" contract intact - every - /// / returned here behaves the same - /// as one obtained through or - /// : it fires events, stays in - /// sync with the server, and shows up in . - /// Mirrors the behaviour of MessageSearchSource in the JavaScript SDK. + /// Default: true. The results behave the same as channels and messages obtained + /// through or + /// : they stay in sync with + /// the server and the channels show up in . /// /// /// - /// Set to false when a search UI shouldn't subscribe to every result channel up front - - /// e.g. a "search bar" where the user opens one hit at a time. In that mode the result - /// and its parent will not receive - /// realtime updates until the channel is explicitly watched. Call + /// Set to false when you only need a one-off snapshot and don't want to start + /// watching every channel in the result set - for example a "search bar" where the user + /// opens one hit at a time. The result and its parent + /// then won't receive realtime updates until you call /// on the result's - /// when the user opens a hit to start receiving updates on that same instance. Use - /// / to check - /// whether a given instance is currently receiving updates. - /// - /// - /// - /// Cost when true: one channel watch round-trip per distinct CID in the result set - /// (parallelised internally). + /// (e.g. when the user opens a hit). Use / + /// to check whether a given instance is receiving + /// updates. /// /// public bool WatchResultChannels { get; set; } = true; diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs index 39da0f43..6d5f5e89 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs @@ -183,26 +183,25 @@ public interface IStreamMessage : IStreamStatefulModel bool IsDeleted { get; } /// - /// Whether this message will receive realtime updates (reactions, edits, deletions, etc.) - /// from the server. Reactivity is per-channel: a message is "watched" if and only if - /// its parent is currently watched - /// (). + /// Whether this message receives realtime updates (new reactions, edits, deletions, etc.). + /// A message receives updates only while its parent is being + /// watched, so this returns the same value as of the + /// channel this message belongs to. /// /// - /// Messages obtained through standard paths (, - /// , the channel's own - /// , etc.) are always watched. Messages can be - /// non-watched when surfaced through - /// with set to - /// false, or through threads loaded with + /// Messages obtained through , + /// or a channel's + /// always receive updates. A message may not receive + /// updates when it comes from with + /// set to false, + /// or from with /// set to false. /// /// /// - /// When this is false, events like will not fire on - /// this instance until the parent channel is watched. Promote the channel via - /// to start receiving updates - cache identity - /// is preserved, so this very instance becomes reactive. + /// When this is false, events like won't fire. Call + /// on the parent channel to start receiving updates + /// on this same message instance. /// /// bool IsWatched { get; } diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs index 3af52eaf..ca08e1f1 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamChannel.cs @@ -627,10 +627,6 @@ public async Task WatchAsync() return; } - // Delegate to the client so the watched-channel bookkeeping and the cache update - // go through exactly the same path as GetOrCreateChannelWithIdAsync. Cache identity - // is preserved - the client looks up by (type, id), the cache returns this same - // instance, and MarkChannelWatched flips IsWatched on it. await Client.InternalGetOrCreateChannelWithIdAsync(Type, Id); } diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index 49b7f48f..b07b93c1 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -575,30 +575,7 @@ public async Task SearchMessagesAsync( if (request.WatchResultChannels && distinctChannels.Count > 0) { - cancellationToken.ThrowIfCancellationRequested(); - - // Watch all distinct result channels in parallel. Default-true means this fires - // on every search; serial would multiply latency by the number of distinct - // channels in the result set (typically 1-10 for a 30-result page). - // Skip channels that are already watched - WatchAsync is idempotent but the - // extra round-trip is wasteful when there's nothing to upgrade. - var watchTasks = new List(distinctChannels.Count); - foreach (var channel in distinctChannels.Values) - { - if (channel.IsWatched) - { - continue; - } - - watchTasks.Add(InternalGetOrCreateChannelWithIdAsync(channel.Type, channel.Id)); - } - - if (watchTasks.Count > 0) - { - await Task.WhenAll(watchTasks); - } - - cancellationToken.ThrowIfCancellationRequested(); + await WatchResultChannelsAsync(distinctChannels.Values, cancellationToken); } return new StreamSearchMessagesResponse @@ -707,6 +684,35 @@ private static MessageInternalDTO ProjectSearchResultToMessageDto(SearchResultMe }; } + // The /search endpoint returns channel data but does not start watching those channels, + // so search hits don't receive realtime updates on their own. We watch them with as few + // requests as possible: a single QueryChannels with a `cid IN (...)` filter, batched in + // groups of 30 (the server's page limit) to stay clear of per-request limits. Channels + // that are already watched are skipped. + private async Task WatchResultChannelsAsync(IEnumerable channels, + CancellationToken cancellationToken) + { + var cidsToWatch = channels.Where(c => !c.IsWatched).Select(c => c.Cid).ToList(); + if (cidsToWatch.Count == 0) + { + return; + } + + const int maxChannelsPerQuery = 30; + for (var i = 0; i < cidsToWatch.Count; i += maxChannelsPerQuery) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chunk = cidsToWatch.Skip(i).Take(maxChannelsPerQuery).ToList(); + var filters = new IFieldFilterRule[] + { + Channels.ChannelFilter.Cid.In(chunk), + }; + + await QueryChannelsAsync(filters, limit: chunk.Count); + } + } + private static StreamSearchWarning BuildSearchWarning(SearchWarningInternalDTO dto) { if (dto == null) From 4daa0ddf82840406387a06702c9175aa93d7437d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 29 May 2026 13:21:53 +0200 Subject: [PATCH 16/19] Fix compiler error --- Assets/Plugins/StreamChat/Core/StreamChatClient.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index b07b93c1..5d97928f 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -15,6 +15,7 @@ using StreamChat.Core.State.Caches; using StreamChat.Core.Models; using StreamChat.Core.QueryBuilders.Filters; +using StreamChat.Core.QueryBuilders.Filters.Channels; using StreamChat.Core.QueryBuilders.Sort; using StreamChat.Core.Requests; using StreamChat.Core.Responses; @@ -706,7 +707,7 @@ private async Task WatchResultChannelsAsync(IEnumerable channels var chunk = cidsToWatch.Skip(i).Take(maxChannelsPerQuery).ToList(); var filters = new IFieldFilterRule[] { - Channels.ChannelFilter.Cid.In(chunk), + ChannelFilter.Cid.In(chunk), }; await QueryChannelsAsync(filters, limit: chunk.Count); From c42e115733bb64e05cc0a9746cfd88dff23cc11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 29 May 2026 13:50:38 +0200 Subject: [PATCH 17/19] Fix thread object custom data --- .../Core/StatefulModels/IStreamThread.cs | 5 - .../Core/StatefulModels/StreamThread.cs | 35 +++--- .../Tests/StatefulClient/ThreadsTests.cs | 106 ++++++++++++++++++ 3 files changed, 121 insertions(+), 25 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamThread.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamThread.cs index c7594f0d..7a622f02 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamThread.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamThread.cs @@ -72,11 +72,6 @@ public interface IStreamThread : IStreamStatefulModel /// string CreatedByUserId { get; } - /// - /// Custom data attached to this thread - /// - IReadOnlyDictionary CustomData { get; } - /// /// Date/time of when this thread was deleted /// diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs index 9b257d90..f562e38a 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs @@ -35,8 +35,6 @@ internal sealed class StreamThread : StreamStatefulModelBase, public string CreatedByUserId { get; private set; } - public IReadOnlyDictionary CustomData => _custom; - public DateTimeOffset? DeletedAt { get; private set; } public DateTimeOffset? LastMessageAt { get; private set; } @@ -191,7 +189,11 @@ void IUpdateableFrom.UpdateFromDto Title = GetOrDefault(dto.Title, Title); UpdatedAt = GetOrDefault(dto.UpdatedAt, UpdatedAt); - LoadAdditionalCustom(dto.Custom); + // Thread custom data is returned as top-level fields (API v1), captured by the DTO's + // [JsonExtensionData] AdditionalProperties bag - not under a "custom" key (which stays + // empty until API v2). Mirrors stream-chat-swift (ThreadPayload subtracts known keys) + // and stream-chat-js (constructCustomDataObject collects non-reserved top-level fields). + LoadAdditionalProperties(dto.AdditionalProperties); Updated?.Invoke(this); } @@ -251,7 +253,11 @@ void IUpdateableFrom3.UpdateFromDto( Title = GetOrDefault(dto.Title, Title); UpdatedAt = GetOrDefault(dto.UpdatedAt, UpdatedAt); - LoadAdditionalCustom(dto.Custom); + // Thread custom data is returned as top-level fields (API v1), captured by the DTO's + // [JsonExtensionData] AdditionalProperties bag - not under a "custom" key (which stays + // empty until API v2). Mirrors stream-chat-swift (ThreadPayload subtracts known keys) + // and stream-chat-js (constructCustomDataObject collects non-reserved top-level fields). + LoadAdditionalProperties(dto.AdditionalProperties); Updated?.Invoke(this); } @@ -295,7 +301,11 @@ void IUpdateableFrom2.UpdateFromDto( Title = GetOrDefault(dto.Title, Title); UpdatedAt = GetOrDefault(dto.UpdatedAt, UpdatedAt); - LoadAdditionalCustom(dto.Custom); + // Thread custom data is returned as top-level fields (API v1), captured by the DTO's + // [JsonExtensionData] AdditionalProperties bag - not under a "custom" key (which stays + // empty until API v2). Mirrors stream-chat-swift (ThreadPayload subtracts known keys) + // and stream-chat-js (constructCustomDataObject collects non-reserved top-level fields). + LoadAdditionalProperties(dto.AdditionalProperties); Updated?.Invoke(this); } @@ -453,7 +463,6 @@ protected override string InternalUniqueId protected override StreamThread Self => this; - private readonly Dictionary _custom = new Dictionary(); private readonly List _latestReplies = new List(); private readonly List _read = new List(); private readonly List _threadParticipants = new List(); @@ -545,20 +554,6 @@ private void ResolveChannelTypeAndId(out string channelType, out string channelI $"Cannot resolve the parent channel of thread {ParentMessageId}. Both Channel and ChannelCid are missing or malformed."); } - private void LoadAdditionalCustom(Dictionary custom) - { - _custom.Clear(); - if (custom == null) - { - return; - } - - foreach (var keyValuePair in custom) - { - _custom[keyValuePair.Key] = keyValuePair.Value; - } - } - // Most-recent-replier first; participants without a LastThreadMessageAt // (e.g. mentioned-only, never replied) go last. Mirrors Android's PARTICIPANT_BY_LAST_REPLY. private sealed class ThreadParticipantByLastReplyComparer : IComparer diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs index 97aad7a8..8c5f3325 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs @@ -231,6 +231,112 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest Assert.AreEqual("My Thread Title", thread.Title); } + [UnityTest] + public IEnumerator When_set_thread_custom_data_expect_data_on_thread_object() + => ConnectAndExecute(When_set_thread_custom_data_expect_data_on_thread_object_Async); + + private async Task When_set_thread_custom_data_expect_data_on_thread_object_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var parent = await channel.SendNewMessageAsync("thread parent for custom data"); + await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + ParentId = parent.Id, + ShowInChannel = false, + Text = "reply", + }); + + var thread = await Client.GetThreadAsync(parent.Id); + + var setClanInfo = new ClanData + { + MaxMembers = 50, + Name = "Wild Boards", + Tags = new List + { + "Competitive", + "Legends", + } + }; + + await thread.UpdatePartialAsync(setFields: new Dictionary + { + { "owned_dogs", 5 }, + { + "breakfast", new string[] + { + "donuts" + } + }, + { + "clan_info", setClanInfo + } + }); + + await WaitWhileFalseAsync( + () => new[] { "owned_dogs", "breakfast", "clan_info" }.All(thread.CustomData.ContainsKey)); + + Assert.AreEqual(5, thread.CustomData.Get("owned_dogs")); + + var breakfast = thread.CustomData.Get>("breakfast"); + Assert.Contains("donuts", breakfast); + + var clanInfo = thread.CustomData.Get("clan_info"); + Assert.AreEqual(50, clanInfo.MaxMembers); + Assert.AreEqual("Wild Boards", clanInfo.Name); + Assert.Contains("Competitive", clanInfo.Tags); + } + + [UnityTest] + public IEnumerator When_unset_thread_custom_data_expect_no_data_on_thread_object() + => ConnectAndExecute(When_unset_thread_custom_data_expect_no_data_on_thread_object_Async); + + private async Task When_unset_thread_custom_data_expect_no_data_on_thread_object_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var parent = await channel.SendNewMessageAsync("thread parent for unset custom data"); + await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + ParentId = parent.Id, + ShowInChannel = false, + Text = "reply", + }); + + var thread = await Client.GetThreadAsync(parent.Id); + + await thread.UpdatePartialAsync(setFields: new Dictionary + { + { "owned_dogs", 5 }, + { + "breakfast", new string[] + { + "donuts" + } + } + }); + + await WaitWhileFalseAsync( + () => new[] { "owned_dogs", "breakfast" }.All(thread.CustomData.ContainsKey)); + + Assert.AreEqual(5, thread.CustomData.Get("owned_dogs")); + Assert.Contains("donuts", thread.CustomData.Get>("breakfast")); + + await thread.UpdatePartialAsync(unsetFields: new[] { "owned_dogs", "breakfast" }); + + await WaitWhileTrueAsync( + () => new[] { "owned_dogs", "breakfast" }.All(thread.CustomData.ContainsKey)); + + Assert.IsFalse(thread.CustomData.ContainsKey("owned_dogs")); + Assert.IsFalse(thread.CustomData.ContainsKey("breakfast")); + } + + private class ClanData + { + public int MaxMembers; + public string Name; + public List Tags; + } + [UnityTest] public IEnumerator When_marking_thread_read_expect_no_exception() => ConnectAndExecute(When_marking_thread_read_expect_no_exception_Async); From 48b4d85751f387840ba82c4c204bc8016706fda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 29 May 2026 13:56:28 +0200 Subject: [PATCH 18/19] Add freeing space before the docker image is pulled. Unity image can be very big and sometimes we run out of space before the project import is finished --- .github/workflows/main.ci.cd.workflow.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/main.ci.cd.workflow.yml b/.github/workflows/main.ci.cd.workflow.yml index ae05c3a5..b2d58881 100644 --- a/.github/workflows/main.ci.cd.workflow.yml +++ b/.github/workflows/main.ci.cd.workflow.yml @@ -170,6 +170,11 @@ jobs: - name: Install Git run: git config --global --add safe.directory /github/workspace + - name: Free Disk space + uses: jlumbroso/free-disk-space@v1.2.0 + with: + dotnet: false + - name: Install dependencies (Linux) if: runner.os == 'Linux' run: sudo apt-get update From 19745630dbac49579d7ba115b10a0d2a3f227413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 29 May 2026 14:45:30 +0200 Subject: [PATCH 19/19] Improve comments on the public interface --- .../StreamChat/Core/IStreamChatClient.cs | 25 +++++------ .../Requests/StreamSearchMessagesRequest.cs | 23 +++++------ .../Responses/StreamSearchMessageResult.cs | 27 ++++-------- .../Responses/StreamSearchMessagesResponse.cs | 2 +- .../Core/StatefulModels/IStreamChannel.cs | 41 +++++++++---------- .../Core/StatefulModels/IStreamMessage.cs | 19 ++++----- 6 files changed, 62 insertions(+), 75 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs index 6c322569..e72e016b 100644 --- a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs @@ -119,14 +119,15 @@ public interface IStreamChatClient : IDisposable, IStreamChatClientEventsListene IStreamLocalUserData LocalUserData { get; } /// - /// Channels currently watched by the SDK. Watched channels receive realtime updates + /// Channels you are currently watching. Watched channels receive realtime updates /// (new messages, reactions, member changes, etc.). /// /// - /// Start watching with or - /// ; stop with . - /// Channels returned by other endpoints may not be watched - check - /// on a specific instance to know its state. + /// Start watching with , + /// or ; + /// stop with . Channels returned by other + /// methods may not be watched - check on a specific + /// channel to know its state. /// /// IReadOnlyList WatchedChannels { get; } @@ -278,12 +279,12 @@ Task GetThreadAsync(string parentMessageId, Task QueryThreadsAsync(StreamQueryThreadsRequest request); /// - /// Search messages across the channels the local user can access. - /// - /// Unlike the low-level Client.LowLevelClient.MessageApi.SearchMessagesAsync, results - /// are returned as cached, stateful (and accompanying - /// ) instances - the same objects already in the cache are - /// reused, and they continue to react to realtime WebSocket events. + /// Search messages across the channels the local user can access. Returns the matching + /// results together with their . + /// By default the result channels are watched so the returned messages and channels keep + /// receiving realtime updates; set + /// to false for one-off + /// results that don't need to stay up to date. /// /// /// The requires a channel-level filter (e.g. @@ -296,7 +297,7 @@ Task GetThreadAsync(string parentMessageId, /// Search parameters - channel filter, message filter, query phrase, /// sort, and pagination. /// [Optional] Cancellation token for the request. - /// Stateful results plus pagination cursors. + /// The matching messages and channels plus pagination cursors. /// https://getstream.io/chat/docs/unity/search/?language=unity Task SearchMessagesAsync( StreamSearchMessagesRequest request, diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index f8ac4b8b..ad133435 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -160,26 +160,25 @@ public sealed class StreamSearchMessagesRequest : ISavableTo - /// Whether the result instances and their parent - /// should receive realtime updates (new reactions, edits, - /// deletions, etc.) after the search completes. + /// Whether the result messages and their parent should + /// receive realtime updates (new reactions, edits, deletions, etc.) after the search + /// completes. /// /// /// Default: true. The results behave the same as channels and messages obtained /// through or /// : they stay in sync with - /// the server and the channels show up in . + /// the server and the channels appear in . /// /// /// - /// Set to false when you only need a one-off snapshot and don't want to start - /// watching every channel in the result set - for example a "search bar" where the user - /// opens one hit at a time. The result and its parent - /// then won't receive realtime updates until you call - /// on the result's - /// (e.g. when the user opens a hit). Use / - /// to check whether a given instance is receiving - /// updates. + /// Set to false when you only need one-off results and don't want to start watching + /// every channel in the result set - for example a search bar where the user opens one + /// result at a time. The result messages and their channels then won't receive realtime + /// updates until you call on the result's + /// (e.g. when the user opens a result). Use + /// / to check + /// whether a given result is receiving updates. /// /// public bool WatchResultChannels { get; set; } = true; diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs index 1b1648dc..8b1451e2 100644 --- a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs @@ -3,21 +3,13 @@ namespace StreamChat.Core.Responses { /// - /// A single hit from . - /// - /// - /// and share identity with the rest of the SDK: - /// if you already hold a reference to the same message or channel (e.g. from - /// or any channel's - /// ), the search hit returns that same reference. - /// Changes coming from the server are reflected on the single instance, so updates observed - /// through the search hit are also visible to every other reference to it. - /// + /// A single result from , holding the + /// matching and the it belongs to. /// /// /// By default ( = true) - /// the result is automatically watched, so both and - /// receive realtime updates exactly like objects obtained from + /// the result is watched, so both and + /// receive realtime updates just like channels and messages obtained from /// . If you opted out of that, neither receives /// updates until you call on ; see /// / . @@ -26,19 +18,18 @@ namespace StreamChat.Core.Responses public sealed class StreamSearchMessageResult { /// - /// The matching message. Receives realtime updates (reactions, edits, deletions, ...) - /// whenever its parent is watched. See - /// . + /// The matching message. Receives realtime updates (reactions, edits, deletions, etc.) + /// while its parent is watched. See . /// public IStreamMessage Message { get; internal set; } /// - /// The channel the message belongs to. By default it is watched and appears in - /// , receiving realtime updates. When the + /// The channel the message belongs to. By default it is watched, appears in + /// and receives realtime updates. When the /// request was issued with /// = false, /// this channel does not receive updates - call - /// on it to start receiving them on this same instance. + /// on it to start receiving them. /// public IStreamChannel Channel { get; internal set; } } diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs index 57c8ccf4..8aa4e77c 100644 --- a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs @@ -8,7 +8,7 @@ namespace StreamChat.Core.Responses public sealed class StreamSearchMessagesResponse { /// - /// Stateful, cached message hits in server-defined order. + /// The matching messages. /// public IReadOnlyList Results { get; internal set; } diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs index 6dcb838b..92d97fec 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamChannel.cs @@ -273,25 +273,23 @@ public interface IStreamChannel : IStreamStatefulModel bool IsDirectMessage { get; } /// - /// Whether this channel is currently watched and receiving realtime events - /// (new messages, reactions, threads, member changes, etc.). + /// Whether this channel is currently watched and receiving realtime updates + /// (new messages, reactions, member changes, typing, etc.). /// /// /// Channels returned by and - /// are automatically - /// watched. watches result - /// channels by default; pass - /// = false + /// are watched. + /// watches result channels by default; + /// pass = false /// to opt out. exposes a similar /// flag (default false). - /// Use to start watching and - /// to stop. + /// Use to start watching and to stop. /// /// /// - /// When this is false the channel will NOT fire events like - /// until it is watched again, and - /// will be false for every message in it. + /// While this is false the channel does not fire events like + /// , and is + /// false for every message in it. /// /// bool IsWatched { get; } @@ -538,26 +536,25 @@ Task TruncateAsync(DateTimeOffset? truncatedAt = default, string systemMessage = bool skipPushNotifications = false, bool isHardDelete = false); /// - /// Start watching this channel so that the local state stays in sync with the server through realtime - /// WebSocket events (new messages, reactions, member changes, typing, etc.). Once watched, the channel - /// appears in and becomes true. + /// Start watching this channel so it receives realtime updates (new messages, reactions, + /// member changes, typing, etc.). Once watched, the channel appears in + /// and becomes true. /// /// - /// Useful when this channel was obtained through a non-watching path - e.g. as a hit on + /// Useful for a channel that is not yet watched - for example one returned by /// with - /// set to false, or as the - /// parent channel of a thread loaded with set to - /// false. Cache identity is preserved across the upgrade - the same instance becomes reactive. + /// set to false, + /// or the parent channel of a thread loaded with + /// set to false. /// /// - /// Idempotent: a no-op if the channel is already watched. - /// - /// Counterpart to . + /// Does nothing if the channel is already watched. Counterpart to . /// Task WatchAsync(); /// - /// Stop watching this channel meaning you will no longer receive any updates and it will be removed from + /// Stop watching this channel. It will no longer receive realtime updates and will be removed from . + /// Use to start watching again. /// Task StopWatchingAsync(); diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs index 6d5f5e89..801798cc 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/IStreamMessage.cs @@ -184,24 +184,23 @@ public interface IStreamMessage : IStreamStatefulModel /// /// Whether this message receives realtime updates (new reactions, edits, deletions, etc.). - /// A message receives updates only while its parent is being - /// watched, so this returns the same value as of the - /// channel this message belongs to. + /// A message is watched while its parent is watched, so this + /// always matches of the channel it belongs to. /// /// - /// Messages obtained through , + /// Messages from , /// or a channel's - /// always receive updates. A message may not receive - /// updates when it comes from with + /// are watched. A message may not be watched when it + /// comes from with /// set to false, /// or from with /// set to false. /// /// /// - /// When this is false, events like won't fire. Call + /// While this is false, events like do not fire. Call /// on the parent channel to start receiving updates - /// on this same message instance. + /// for this message. /// /// bool IsWatched { get; } @@ -271,7 +270,7 @@ Task SendReactionAsync(string type, int score = 1, bool enforceUnique = false, /// /// Get the thread for which this message is the parent. The returned - /// is cached and gets updated by real-time events. + /// stays updated by realtime events. /// /// [Optional] Number of replies to fetch /// [Optional] Number of participants to fetch @@ -279,7 +278,7 @@ Task SendReactionAsync(string type, int score = 1, bool enforceUnique = false, /// /// Load replies of this message (a parent message of a thread). Returned messages are oldest-first - /// and are cached, so they show up in if a thread is tracked. + /// and show up in if a thread is tracked. /// /// and are mutually exclusive. /// Pass neither to load the latest replies.