diff --git a/Package.swift b/Package.swift index c27a80ca..22801661 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/GetStream/stream-core-swift.git", exact: "0.2.1") + .package(url: "https://github.com/GetStream/stream-core-swift.git", exact: "0.3.0") ], targets: [ .target( diff --git a/Sources/StreamFeeds/Extensions/Array+Extensions.swift b/Sources/StreamFeeds/Extensions/Array+Extensions.swift index 1ec06935..801e5328 100644 --- a/Sources/StreamFeeds/Extensions/Array+Extensions.swift +++ b/Sources/StreamFeeds/Extensions/Array+Extensions.swift @@ -273,6 +273,15 @@ extension Array where Element: Identifiable { case binarySearch(Element) } + func firstSortedIndex(of matchingElement: Element, sorting: (Element, Element) -> Bool) -> Index? { + // Here we are looking for existing element which might have a different state + // therefore if binary search fails, we still need to do linear search. + if let index = firstBinarySearchIndex(for: matchingElement, sorting: sorting) { + return index + } + return firstIndex(where: { $0.id == matchingElement.id }) + } + @discardableResult private mutating func _sortedUpdate( searchStrategy: ElementSearch, nesting nestingKeyPath: WritableKeyPath?, @@ -287,12 +296,7 @@ extension Array where Element: Identifiable { case .linear(let matchingId): return updatedElements.firstIndex(where: { $0.id == matchingId }) case .binarySearch(let matchingElement): - // Here we are looking for existing element which might have a different state - // therefore if binary search fails, we still need to do linear search. - if let index = firstSortedIndex(for: matchingElement, sorting: sorting) { - return index - } - return updatedElements.firstIndex(where: { $0.id == matchingElement.id }) + return updatedElements.firstSortedIndex(of: matchingElement, sorting: sorting) } }() if let matchingIndex { @@ -334,7 +338,7 @@ extension Array where Element: Identifiable { _sortedUpdate(searchStrategy: .linear(id), nesting: nestingKeyPath, sorting: { _, _ in true }, changes: changes) } - private func firstSortedIndex(for element: Element, sorting: (Element, Element) -> Bool) -> Index? { + private func firstBinarySearchIndex(for element: Element, sorting: (Element, Element) -> Bool) -> Index? { var left = startIndex var right = endIndex while left < right { diff --git a/Sources/StreamFeeds/Models/BookmarkFolderData.swift b/Sources/StreamFeeds/Models/BookmarkFolderData.swift index fbb006e4..5fd00a45 100644 --- a/Sources/StreamFeeds/Models/BookmarkFolderData.swift +++ b/Sources/StreamFeeds/Models/BookmarkFolderData.swift @@ -11,8 +11,6 @@ public struct BookmarkFolderData: Identifiable, Equatable, Sendable { public let id: String public let name: String public let updatedAt: Date - - var localFilterData: LocalFilterData? } // MARK: - Model Conversions @@ -28,17 +26,3 @@ extension BookmarkFolderResponse { ) } } - -// MARK: - Local Filter Matching - -extension BookmarkFolderData { - struct LocalFilterData: Equatable, Sendable { - var userId: String = "" - } - - func toLocalFilterModel(userId: String) -> Self { - var data = self - data.localFilterData = LocalFilterData(userId: userId) - return data - } -} diff --git a/Sources/StreamFeeds/Models/FeedData.swift b/Sources/StreamFeeds/Models/FeedData.swift index eefda6db..d138ffdc 100644 --- a/Sources/StreamFeeds/Models/FeedData.swift +++ b/Sources/StreamFeeds/Models/FeedData.swift @@ -25,8 +25,6 @@ public struct FeedData: Identifiable, Equatable, Sendable { public let pinCount: Int public let updatedAt: Date public let visibility: String? - - var localFilterData: LocalFilterData? } // MARK: - Model Conversions @@ -56,21 +54,3 @@ extension FeedResponse { ) } } - -// MARK: - Local Filter Matching - -extension FeedData { - struct LocalFilterData: Equatable, Sendable { - var followingFeedIds: [String] - var memberIds: [String] - } - - func toLocalFilterModel( - followingFeedIds: [String], - memberIds: [String] - ) -> Self { - var data = self - data.localFilterData = LocalFilterData(followingFeedIds: followingFeedIds, memberIds: memberIds) - return data - } -} diff --git a/Sources/StreamFeeds/StateLayer/ActivityState.swift b/Sources/StreamFeeds/StateLayer/ActivityState.swift index f05ab2f4..ff17e899 100644 --- a/Sources/StreamFeeds/StateLayer/ActivityState.swift +++ b/Sources/StreamFeeds/StateLayer/ActivityState.swift @@ -10,7 +10,7 @@ import StreamCore /// /// This class manages the state of a single activity including its comments, poll data, and real-time updates. /// It automatically updates when WebSocket events are received and provides change handlers for state modifications. -@MainActor public class ActivityState: ObservableObject { +@MainActor public final class ActivityState: ObservableObject, StateAccessing { private var cancellables = Set() private let commentListState: ActivityCommentListState let currentUserId: String @@ -148,8 +148,4 @@ extension ActivityState { self.activity = activity poll = activity?.poll } - - private func access(_ actions: @MainActor (ActivityState) -> T) -> T { - actions(self) - } } diff --git a/Sources/StreamFeeds/StateLayer/Common/StateAccessing.swift b/Sources/StreamFeeds/StateLayer/Common/StateAccessing.swift new file mode 100644 index 00000000..8e075035 --- /dev/null +++ b/Sources/StreamFeeds/StateLayer/Common/StateAccessing.swift @@ -0,0 +1,19 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A protocol that provides thread-safe access to state objects. +/// +/// This protocol allows for safe access to state properties and methods +/// from any thread by ensuring the access happens on the main actor. +@MainActor protocol StateAccessing { + @discardableResult func access(_ actions: @MainActor (Self) -> T) -> T +} + +extension StateAccessing { + @discardableResult func access(_ actions: @MainActor (Self) -> T) -> T { + actions(self) + } +} diff --git a/Sources/StreamFeeds/StateLayer/FeedState.swift b/Sources/StreamFeeds/StateLayer/FeedState.swift index 6c77456b..5220066c 100644 --- a/Sources/StreamFeeds/StateLayer/FeedState.swift +++ b/Sources/StreamFeeds/StateLayer/FeedState.swift @@ -10,7 +10,7 @@ import StreamCore /// /// This class manages the state of a feed including activities, followers, members, and pagination information. /// It automatically updates when WebSocket events are received and provides change handlers for state modifications. -@MainActor public class FeedState: ObservableObject { +@MainActor public final class FeedState: ObservableObject, StateAccessing { private var cancellables = Set() private let currentUserId: String let memberListState: MemberListState @@ -376,10 +376,6 @@ extension FeedState { } } - @discardableResult func access(_ actions: @MainActor (FeedState) -> T) -> T { - actions(self) - } - private func updateActivity(_ activityData: ActivityData) { activities.sortedUpdate( ofId: activityData.id, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift index 12221ea6..a34c3f4a 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift @@ -47,7 +47,7 @@ import Foundation /// /// This class is designed to run on the main actor and all state updates /// are performed on the main thread to ensure UI consistency. -@MainActor public class ActivityCommentListState: ObservableObject { +@MainActor public final class ActivityCommentListState: ObservableObject, StateAccessing { private let currentUserId: String private var eventSubscription: StateLayerEventPublisher.Subscription? @@ -128,6 +128,11 @@ extension ActivityCommentListState { private func subscribe(to publisher: StateLayerEventPublisher) { eventSubscription = publisher.subscribe { [weak self, currentUserId, query] event in switch event { + case .activityDeleted(let activityId, _): + guard query.objectId == activityId else { return } + await self?.access { state in + state.comments.removeAll() + } case .commentAdded(let commentData, _, _): guard query.objectId == commentData.objectId, query.objectType == commentData.objectType else { return } await self?.access { state in @@ -225,10 +230,6 @@ extension ActivityCommentListState { } } - @discardableResult func access(_ actions: @MainActor (ActivityCommentListState) -> T) -> T { - actions(self) - } - func didPaginate(with response: PaginationResult) { pagination = response.pagination comments = comments.sortedMerge(response.models, sorting: CommentsSort.areInIncreasingOrder(sortingKey)) diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift index feaec925..21aafbc0 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift @@ -19,7 +19,7 @@ import StreamCore /// // Update UI with new activities /// } /// ``` -@MainActor public class ActivityListState: ObservableObject { +@MainActor public final class ActivityListState: ObservableObject, StateAccessing { private let currentUserId: String private var eventSubscription: StateLayerEventPublisher.Subscription? @@ -88,9 +88,13 @@ extension ActivityListState { state.activities.sortedInsert(activityData, sorting: state.activitiesSorting) } case .activityUpdated(let activityData, _): - guard matchesQuery(activityData) else { return } + let matches = matchesQuery(activityData) await self?.access { state in - state.activities.sortedInsert(activityData, sorting: state.activitiesSorting) + if matches { + state.activities.sortedInsert(activityData, sorting: state.activitiesSorting) + } else { + state.activities.remove(byId: activityData.id) + } } case .activityDeleted(let activityId, _): await self?.access { state in @@ -221,10 +225,6 @@ extension ActivityListState { } } - @discardableResult func access(_ actions: @MainActor (ActivityListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityReactionListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityReactionListState.swift index daa85b13..c7540473 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityReactionListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityReactionListState.swift @@ -33,7 +33,7 @@ import StreamCore /// ## Thread Safety /// /// This class is marked with `@MainActor` and should only be accessed from the main thread. -@MainActor public class ActivityReactionListState: ObservableObject { +@MainActor public final class ActivityReactionListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? init(query: ActivityReactionsQuery, eventPublisher: StateLayerEventPublisher) { @@ -116,8 +116,13 @@ extension ActivityReactionListState { } case .activityReactionUpdated(let reactionData, let activityData, _): guard activityData.id == query.activityId else { return } + let matches = matchesQuery(reactionData) await self?.access { state in - state.reactions.sortedReplace(reactionData, nesting: nil, sorting: state.reactionsSorting) + if matches { + state.reactions.sortedReplace(reactionData, nesting: nil, sorting: state.reactionsSorting) + } else { + state.reactions.remove(byId: reactionData.id) + } } case .userUpdated(let userData): await self?.access { state in @@ -132,10 +137,6 @@ extension ActivityReactionListState { } } - @discardableResult func access(_ actions: @MainActor (ActivityReactionListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift index 209a8bad..56305964 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift @@ -6,7 +6,7 @@ import Combine import Foundation import StreamCore -@MainActor public class BookmarkFolderListState: ObservableObject { +@MainActor public final class BookmarkFolderListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? init(query: BookmarkFoldersQuery, eventPublisher: StateLayerEventPublisher) { @@ -42,6 +42,10 @@ import StreamCore extension BookmarkFolderListState { private func subscribe(to publisher: StateLayerEventPublisher) { + let matchesQuery: @Sendable (BookmarkFolderData) -> Bool = { [query] bookmarkFolder in + guard let filter = query.filter else { return true } + return filter.matches(bookmarkFolder) + } eventSubscription = publisher.subscribe { [weak self] event in switch event { case .bookmarkFolderDeleted(let folder): @@ -49,8 +53,13 @@ extension BookmarkFolderListState { state.folders.remove(byId: folder.id) } case .bookmarkFolderUpdated(let folder): + let matches = matchesQuery(folder) await self?.access { state in - state.folders.sortedReplace(folder, nesting: nil, sorting: state.bookmarksSorting) + if matches { + state.folders.sortedReplace(folder, nesting: nil, sorting: state.bookmarksSorting) + } else { + state.folders.remove(byId: folder.id) + } } default: break @@ -58,10 +67,6 @@ extension BookmarkFolderListState { } } - @discardableResult func access(_ actions: @MainActor (BookmarkFolderListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFoldersQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFoldersQuery.swift index 7c2c7956..22fbad44 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFoldersQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFoldersQuery.swift @@ -98,7 +98,7 @@ extension BookmarkFoldersFilterField { /// Filter by the user ID who owns the bookmark folder. /// /// **Supported operators:** `.equal`, `.in` - public static let userId = Self("user_id", localValue: \.localFilterData?.userId) + public static let userId = Self("user_id", localValue: { _ -> String? in nil /* local data unavailable (FEEDS-801) */ }) } /// A filter that can be applied to bookmark folders queries. diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift index 93717d97..123847a7 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift @@ -6,7 +6,7 @@ import Combine import Foundation import StreamCore -@MainActor public class BookmarkListState: ObservableObject { +@MainActor public final class BookmarkListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? init(query: BookmarksQuery, eventPublisher: StateLayerEventPublisher) { @@ -42,6 +42,10 @@ import StreamCore extension BookmarkListState { private func subscribe(to publisher: StateLayerEventPublisher) { + let matchesQuery: @Sendable (BookmarkData) -> Bool = { [query] bookmark in + guard let filter = query.filter else { return true } + return filter.matches(bookmark) + } eventSubscription = publisher.subscribe { [weak self] event in switch event { case .bookmarkFolderDeleted(let folder): @@ -59,8 +63,13 @@ extension BookmarkListState { ) } case .bookmarkUpdated(let bookmark): + let matches = matchesQuery(bookmark) await self?.access { state in - state.bookmarks.sortedReplace(bookmark, nesting: nil, sorting: state.bookmarkFoldersSorting) + if matches { + state.bookmarks.sortedReplace(bookmark, nesting: nil, sorting: state.bookmarkFoldersSorting) + } else { + state.bookmarks.remove(byId: bookmark.id) + } } default: break @@ -75,10 +84,6 @@ extension BookmarkListState { bookmarks[index] = bookmark } - func access(_ actions: @MainActor (BookmarkListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentListState.swift index f484b99e..525cb734 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentListState.swift @@ -5,7 +5,7 @@ import Combine import Foundation -@MainActor public class CommentListState: ObservableObject { +@MainActor public final class CommentListState: ObservableObject, StateAccessing { private let currentUserId: String private var eventSubscription: StateLayerEventPublisher.Subscription? @@ -62,13 +62,17 @@ extension CommentListState { guard matchesQuery(commentData) else { return } await self?.access { $0.comments.remove(byId: commentData.id) } case .commentUpdated(let commentData, _, _): - guard matchesQuery(commentData) else { return } + let matches = matchesQuery(commentData) await self?.access { state in - state.comments.sortedReplace( - commentData, - nesting: nil, - sorting: CommentsSort.areInIncreasingOrder(state.sortingKey) - ) + if matches { + state.comments.sortedReplace( + commentData, + nesting: nil, + sorting: CommentsSort.areInIncreasingOrder(state.sortingKey) + ) + } else { + state.comments.remove(byId: commentData.id) + } } case .commentReactionAdded(let feedsReactionData, let commentData, _): guard matchesQuery(commentData) else { return } @@ -113,10 +117,6 @@ extension CommentListState { } } - @discardableResult func access(_ actions: @MainActor (CommentListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, filter: CommentsFilter?, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReactionListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReactionListState.swift index a80038b4..a38b3382 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReactionListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReactionListState.swift @@ -31,7 +31,7 @@ import StreamCore /// ## Thread Safety /// /// This class is marked with `@MainActor` and should only be accessed from the main thread. -@MainActor public class CommentReactionListState: ObservableObject { +@MainActor public final class CommentReactionListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? init(query: CommentReactionsQuery, eventPublisher: StateLayerEventPublisher) { @@ -107,22 +107,37 @@ import StreamCore extension CommentReactionListState { private func subscribe(to publisher: StateLayerEventPublisher) { + let matchesQuery: @Sendable (FeedsReactionData) -> Bool = { [query] reaction in + guard let filter = query.filter else { return true } + return filter.matches(reaction) + } eventSubscription = publisher.subscribe { [weak self, query] event in switch event { - case let .commentReactionAdded(reaction, comment, _): + case .commentDeleted(let comment, _, _): + guard comment.id == query.commentId else { return } + await self?.access { state in + state.reactions.removeAll() + } + case .commentReactionAdded(let reaction, let comment, _): guard comment.id == query.commentId else { return } + guard matchesQuery(reaction) else { return } await self?.access { state in state.reactions.sortedInsert(reaction, sorting: state.reactionsSorting) } - case let .commentReactionDeleted(reaction, comment, _): + case .commentReactionDeleted(let reaction, let comment, _): guard comment.id == query.commentId else { return } await self?.access { state in state.reactions.remove(byId: reaction.id) } - case let .commentReactionUpdated(reaction, comment, _): + case .commentReactionUpdated(let reaction, let comment, _): guard comment.id == query.commentId else { return } + let matches = matchesQuery(reaction) await self?.access { state in - state.reactions.sortedReplace(reaction, nesting: nil, sorting: state.reactionsSorting) + if matches { + state.reactions.sortedReplace(reaction, nesting: nil, sorting: state.reactionsSorting) + } else { + state.reactions.remove(byId: reaction.id) + } } default: break @@ -130,10 +145,6 @@ extension CommentReactionListState { } } - @discardableResult func access(_ actions: @MainActor (CommentReactionListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReplyListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReplyListState.swift index d898f0f4..159708ef 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReplyListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReplyListState.swift @@ -46,7 +46,7 @@ import Foundation /// /// This class is designed to run on the main actor and all state updates /// are performed on the main thread to ensure UI consistency. -@MainActor public class CommentReplyListState: ObservableObject { +@MainActor public final class CommentReplyListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? private let currentUserId: String @@ -216,10 +216,6 @@ extension CommentReplyListState { } } - @discardableResult func access(_ actions: @MainActor (CommentReplyListState) -> T) -> T { - actions(self) - } - func didPaginate(with response: PaginationResult) { pagination = response.pagination replies = replies.sortedMerge(response.models, sorting: CommentsSort.areInIncreasingOrder(query.sort ?? .last)) diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift index b9af0555..d402f83a 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift @@ -2,18 +2,26 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Combine import Foundation import StreamCore public final class FeedList: Sendable { - @MainActor private let stateBuilder: StateBuilder private let feedsRepository: FeedsRepository + private let disposableBag = DisposableBag() + private let refetchSubject = AllocatedUnfairLock(PassthroughSubject()) + private let refetchDelay: Int + @MainActor private let stateBuilder: StateBuilder - init(query: FeedsQuery, client: FeedsClient) { + init(query: FeedsQuery, client: FeedsClient, refetchDelay: Int = 5) { feedsRepository = client.feedsRepository self.query = query + self.refetchDelay = refetchDelay let eventPublisher = client.stateLayerEventPublisher - stateBuilder = StateBuilder { FeedListState(query: query, eventPublisher: eventPublisher) } + stateBuilder = StateBuilder { [refetchSubject] in + FeedListState(query: query, eventPublisher: eventPublisher, refetchSubject: refetchSubject) + } + subscribeToRefetch() } public let query: FeedsQuery @@ -55,4 +63,43 @@ public final class FeedList: Sendable { ) return result.models } + + /// Refetches all the feeds and updates the state once when pagination has ended. + private func subscribeToRefetch() { + refetchSubject.withLock { [disposableBag, refetchDelay] subject in + subject + .debounce(for: .seconds(refetchDelay), scheduler: DispatchQueue.global(qos: .utility)) + .asyncSink { [weak self] _ in + guard let self else { return } + do { + let batches = await self.state.access { state in + let limit = state.feeds.count + let pageSize = 25 + return stride(from: 0, to: limit, by: pageSize).map { min(pageSize, limit - $0) } + } + guard !batches.isEmpty else { return } + var next: String? + var refetchedFeeds = [FeedData]() + for batch in batches { + let result = try await self.feedsRepository.queryFeeds( + with: FeedsQuery( + filter: query.filter, + sort: query.sort, + limit: batch, + next: next, + previous: nil, + watch: query.watch + ) + ) + next = result.pagination.next + refetchedFeeds.append(contentsOf: result.models) + } + await self.state.didRefetch(refetchedFeeds) + } catch { + log.error("Failed to refetch", subsystems: .other, error: error) + } + } + .store(in: disposableBag) + } + } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift index 03b189ff..c1fe8734 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift @@ -6,11 +6,15 @@ import Combine import Foundation import StreamCore -@MainActor public class FeedListState: ObservableObject { +@MainActor public final class FeedListState: ObservableObject, StateAccessing { + private var canFilterLocally: Bool private var eventSubscription: StateLayerEventPublisher.Subscription? + private let refetchSubject: AllocatedUnfairLock> - init(query: FeedsQuery, eventPublisher: StateLayerEventPublisher) { + init(query: FeedsQuery, eventPublisher: StateLayerEventPublisher, refetchSubject: AllocatedUnfairLock>) { self.query = query + self.canFilterLocally = query.canFilterLocally + self.refetchSubject = refetchSubject subscribe(to: eventPublisher) } @@ -46,20 +50,47 @@ extension FeedListState { guard let filter = query.filter else { return true } return filter.matches(feedData) } - eventSubscription = publisher.subscribe { [weak self] event in + eventSubscription = publisher.subscribe { [weak self, canFilterLocally, refetchSubject] event in switch event { case .feedAdded(let feed, _): - guard matchesQuery(feed) else { return } - await self?.access { state in - state.feeds.sortedInsert(feed, sorting: state.feedsSorting) + if canFilterLocally { + guard matchesQuery(feed) else { return } + await self?.access { state in + state.feeds.sortedInsert(feed, sorting: state.feedsSorting) + } + } else { + // Refetch data for determing if the added feed is part of the current query + refetchSubject.withLock { $0.send() } } case .feedDeleted(let feedId): await self?.access { state in state.feeds.remove(byId: feedId.rawValue) } case .feedUpdated(let feed, _): - await self?.access { state in - state.feeds.sortedReplace(feed, nesting: nil, sorting: state.feedsSorting) + guard let self else { return } + if canFilterLocally { + let matches = matchesQuery(feed) + await self.access { state in + if matches { + state.feeds.sortedReplace(feed, nesting: nil, sorting: state.feedsSorting) + } else { + state.feeds.remove(byId: feed.id) + } + } + } else { + // Update can mean that the feed is not part of the query anymore + let needsRefetch = await self.access { state in + // If we have this feed loaded, update its state, but since we do not know if it matches to the query, we should refetch all + if let index = state.feeds.firstSortedIndex(of: feed, sorting: state.feedsSorting.areInIncreasingOrder()) { + state.feeds[index] = feed + return true + } else { + // No need to refetch because it was not returned by the API before + return false + } + } + guard needsRefetch else { return } + refetchSubject.withLock { $0.send() } } default: break @@ -67,10 +98,6 @@ extension FeedListState { } } - @discardableResult func access(_ actions: @MainActor (FeedListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration @@ -79,4 +106,10 @@ extension FeedListState { self.queryConfig = queryConfig feeds = feeds.sortedMerge(response.models, sorting: feedsSorting) } + + func didRefetch(_ models: [FeedData]) { + guard !models.isEmpty else { return } + let existing = feeds.dropFirst(models.count) + feeds = Array(existing).sortedMerge(models, sorting: feedsSorting) + } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedsQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedsQuery.swift index c38fc2fd..1df4a9b5 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedsQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedsQuery.swift @@ -148,7 +148,7 @@ extension FeedsFilterField { /// Filter by specific members in the feed. /// /// **Supported operators:** `.in` - public static let members = Self("members", localValue: \.localFilterData?.memberIds) + public static let members = Self("members", localValue: { _ in [String]() /* local data unavailable */ }) /// Filter by the name of the feed. /// @@ -168,7 +168,7 @@ extension FeedsFilterField { /// Filter by feeds that this feed is following. /// /// **Supported operators:** `.in` - public static let followingFeeds = Self("following_feeds", localValue: \.localFilterData?.followingFeedIds) + public static let followingFeeds = Self("following_feeds", localValue: { _ in [String]() /* local data unavailable */ }) /// Filter by filter tags associated with the feed. /// @@ -304,4 +304,10 @@ extension FeedsQuery { watch: watch ) } + + var canFilterLocally: Bool { + guard let filter else { return true } + // No local data for these keys + return !filter.contains(.members) && !filter.contains(.followingFeeds) + } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift index 281eb5e4..88cb0760 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift @@ -6,7 +6,7 @@ import Combine import Foundation import StreamCore -@MainActor public class FollowListState: ObservableObject { +@MainActor public final class FollowListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? init(query: FollowsQuery, eventPublisher: StateLayerEventPublisher) { @@ -58,8 +58,13 @@ extension FollowListState { state.follows.remove(byId: follow.id) } case let .feedFollowUpdated(follow, _): + let matches = matchesQuery(follow) await self?.access { state in - state.follows.sortedReplace(follow, nesting: nil, sorting: state.followsSorting) + if matches { + state.follows.sortedReplace(follow, nesting: nil, sorting: state.followsSorting) + } else { + state.follows.remove(byId: follow.id) + } } default: break @@ -67,10 +72,6 @@ extension FollowListState { } } - @discardableResult func access(_ actions: @MainActor (FollowListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift index 4ecfabbe..7a17fb73 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift @@ -11,7 +11,7 @@ import StreamCore /// `MemberListState` maintains the current list of members, pagination information, /// and provides real-time updates when members are added, removed, or modified. /// It automatically handles WebSocket events to keep the member list synchronized. -@MainActor public class MemberListState: ObservableObject { +@MainActor public final class MemberListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? init(query: MembersQuery, eventPublisher: StateLayerEventPublisher) { @@ -66,7 +66,7 @@ extension MemberListState { private func subscribe(to publisher: StateLayerEventPublisher) { let matchesQuery: @Sendable (FeedMemberData) -> Bool = { [query] member in guard let filter = query.filter else { return true } - return filter.matches(member) + return filter.matches(member.toLocalFilterModel(feed: query.feed)) } eventSubscription = publisher.subscribe { [weak self, query] event in switch event { @@ -83,15 +83,22 @@ extension MemberListState { } case .feedMemberUpdated(let memberData, let eventFeedId): guard eventFeedId == query.feed else { return } + let matches = matchesQuery(memberData) await self?.access { state in - state.members.sortedReplace(memberData, nesting: nil, sorting: state.membersSorting) + if matches { + state.members.sortedReplace(memberData, nesting: nil, sorting: state.membersSorting) + } else { + state.members.remove(byId: memberData.id) + } } case .feedMemberBatchUpdate(let updates, let eventFeedId): guard eventFeedId == query.feed else { return } let added = updates.added.filter(matchesQuery) + let updatedNotMatching = updates.updated.filter { !matchesQuery($0) }.map(\.id) await self?.access { state in added.forEach { state.members.sortedInsert($0, sorting: state.membersSorting) } updates.updated.forEach { state.members.sortedReplace($0, nesting: nil, sorting: state.membersSorting) } + state.members.remove(byIds: updatedNotMatching) state.members.remove(byIds: updates.removedIds) } default: @@ -100,10 +107,6 @@ extension MemberListState { } } - @discardableResult func access(_ actions: @MainActor (MemberListState) -> T) -> T { - actions(self) - } - func applyUpdates(_ updates: ModelUpdates) { // Skip added because the it might not belong to this list members.replace(byIds: updates.updated) diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigListState.swift index 8a4981e0..86e9b122 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigListState.swift @@ -46,7 +46,7 @@ import StreamCore /// /// This class is designed to run on the main actor and all state updates /// are performed on the main thread to ensure UI consistency. -@MainActor public class ModerationConfigListState: ObservableObject { +@MainActor public final class ModerationConfigListState: ObservableObject, StateAccessing { init(query: ModerationConfigsQuery) { self.query = query } @@ -89,10 +89,6 @@ import StreamCore // MARK: - Updating the State extension ModerationConfigListState { - func access(_ actions: @MainActor (ModerationConfigListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollListState.swift index 7e6a7a1f..356373fe 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollListState.swift @@ -6,7 +6,7 @@ import Combine import Foundation import StreamCore -@MainActor public class PollListState: ObservableObject { +@MainActor public final class PollListState: ObservableObject, StateAccessing { private let currentUserId: String private var eventSubscription: StateLayerEventPublisher.Subscription? @@ -50,13 +50,18 @@ extension PollListState { } eventSubscription = publisher.subscribe { [weak self, currentUserId] event in switch event { - case let .pollDeleted(pollId, _): + case .pollDeleted(let pollId, _): await self?.access { state in state.polls.remove(byId: pollId) } - case let .pollUpdated(poll, _): + case .pollUpdated(let poll, _): + let matches = matchesQuery(poll) await self?.access { state in - state.polls.sortedReplace(poll, nesting: nil, sorting: state.pollsSorting) + if matches { + state.polls.sortedReplace(poll, nesting: nil, sorting: state.pollsSorting) + } else { + state.polls.remove(byId: poll.id) + } } case .pollVoteCasted(let vote, let pollData, _): guard matchesQuery(pollData) else { return } @@ -94,10 +99,6 @@ extension PollListState { } } - @discardableResult func access(_ actions: @MainActor (PollListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVoteListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVoteListState.swift index 1dc17910..d3f82781 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVoteListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVoteListState.swift @@ -6,7 +6,7 @@ import Combine import Foundation import StreamCore -@MainActor public class PollVoteListState: ObservableObject { +@MainActor public final class PollVoteListState: ObservableObject, StateAccessing { private var eventSubscription: StateLayerEventPublisher.Subscription? init(query: PollVotesQuery, eventPublisher: StateLayerEventPublisher) { @@ -56,9 +56,13 @@ extension PollVoteListState { } case .pollVoteChanged(let vote, let pollData, _): guard pollData.id == query.pollId else { return } - guard matchesQuery(vote) else { return } + let matches = matchesQuery(vote) await self?.access { state in - state.votes.sortedReplace(vote, nesting: nil, sorting: state.pollVotesSorting) + if matches { + state.votes.sortedReplace(vote, nesting: nil, sorting: state.pollVotesSorting) + } else { + state.votes.remove(byId: vote.id) + } } case .pollVoteDeleted(let vote, let pollData, _): guard pollData.id == query.pollId else { return } @@ -72,10 +76,6 @@ extension PollVoteListState { } } - @discardableResult func access(_ actions: @MainActor (PollVoteListState) -> T) -> T { - actions(self) - } - func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/StreamFeeds.xcodeproj/project.pbxproj b/StreamFeeds.xcodeproj/project.pbxproj index e49f8a0c..b36762d2 100644 --- a/StreamFeeds.xcodeproj/project.pbxproj +++ b/StreamFeeds.xcodeproj/project.pbxproj @@ -864,7 +864,7 @@ repositoryURL = "https://github.com/GetStream/stream-core-swift.git"; requirement = { kind = exactVersion; - version = 0.2.1; + version = 0.3.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift index 03cd386d..589ad2c5 100644 --- a/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/ActivityCommentList_Tests.swift @@ -330,6 +330,46 @@ struct ActivityCommentList_Tests { #expect(result == ["comment-1"]) // Should not include comment-2 } + @Test func activityDeletedEventClearsAllComments() async throws { + let client = defaultClient( + comments: [ + .dummy(id: "comment-1", objectId: Self.activityId), + .dummy(id: "comment-2", objectId: Self.activityId) + ] + ) + let commentList = client.activityCommentList( + for: .init(objectId: Self.activityId, objectType: "activity") + ) + try await commentList.get() + + // Verify initial state has comments + let initialComments = await commentList.state.comments + #expect(initialComments.count == 2) + #expect(initialComments.map(\.id) == ["comment-1", "comment-2"]) + + // Send activity deleted event for different activity - should not affect comments + await client.eventsMiddleware.sendEvent( + ActivityDeletedEvent.dummy( + activityId: "different-activity", + fid: "user:test" + ) + ) + + let commentsAfterUnrelatedEvent = await commentList.state.comments + #expect(commentsAfterUnrelatedEvent.count == 2) // Should remain unchanged + + // Send activity deleted event for matching activity - should clear all comments + await client.eventsMiddleware.sendEvent( + ActivityDeletedEvent.dummy( + activityId: Self.activityId, + fid: "user:test" + ) + ) + + let commentsAfterDeletion = await commentList.state.comments + #expect(commentsAfterDeletion.isEmpty) // All comments should be removed + } + // MARK: - private func defaultClient( diff --git a/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift index aea2e3a4..db1dbbf4 100644 --- a/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/ActivityList_Tests.swift @@ -111,6 +111,35 @@ struct ActivityList_Tests { #expect(activities.first?.text == "Updated text") } + @Test func activityUpdatedEventRemovesActivityWhenNoLongerMatchingQuery() async throws { + let client = defaultClient() + let activityList = client.activityList( + for: ActivitiesQuery( + filter: .equal(.userId, "current-user-id") + ) + ) + try await activityList.get() + + // Verify initial state has the activity + let initialActivities = await activityList.state.activities + #expect(initialActivities.count == 1) + #expect(initialActivities.first?.id == "activity-1") + #expect(initialActivities.first?.user.id == "current-user-id") + + // Send activity updated event where the user changes to someone else + // This should cause the activity to no longer match the query filter + await client.eventsMiddleware.sendEvent( + ActivityUpdatedEvent.dummy( + activity: .dummy(id: "activity-1", text: "Updated text", user: .dummy(id: "other-user")), + fid: Self.feedId.rawValue + ) + ) + + // Activity should be removed since it no longer matches the userId filter + let activitiesAfterUpdate = await activityList.state.activities + #expect(activitiesAfterUpdate.isEmpty) + } + @Test func activityDeletedEventRemovesFromState() async throws { let client = defaultClient() let activityList = client.activityList( diff --git a/Tests/StreamFeedsTests/StateLayer/BookmarkFolderList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/BookmarkFolderList_Tests.swift index 2e9a5571..91413298 100644 --- a/Tests/StreamFeedsTests/StateLayer/BookmarkFolderList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/BookmarkFolderList_Tests.swift @@ -206,6 +206,45 @@ struct BookmarkFolderList_Tests { #expect(updatedState.first { $0.id == "folder-2" }?.name == "Second Folder") } + @Test func bookmarkFolderUpdatedEventRemovesFolderWhenNoLongerMatchingQuery() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarkFoldersResponse.dummy( + bookmarkFolders: [ + .dummy(id: "folder-1", name: "First Folder") + ], + next: "next-cursor" + ) + ] + ) + ) + let bookmarkFolderList = client.bookmarkFolderList( + for: BookmarkFoldersQuery( + filter: .equal(.folderName, "First Folder") + ) + ) + try await bookmarkFolderList.get() + + // Verify initial state has the folder that matches the filter + let initialFolders = await bookmarkFolderList.state.folders + #expect(initialFolders.count == 1) + #expect(initialFolders.first?.id == "folder-1") + #expect(initialFolders.first?.name == "First Folder") + + // Send bookmark folder updated event where the name changes to something that doesn't match the filter + // This should cause the folder to no longer match the query filter + await client.eventsMiddleware.sendEvent( + BookmarkFolderUpdatedEvent.dummy( + bookmarkFolder: .dummy(id: "folder-1", name: "Updated Folder Name") + ) + ) + + // Folder should be removed since it no longer matches the folderName filter + let foldersAfterUpdate = await bookmarkFolderList.state.folders + #expect(foldersAfterUpdate.isEmpty) + } + // MARK: - Helper Methods private func defaultClientWithBookmarkFolderResponses( diff --git a/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift index d55c1b2d..618262fd 100644 --- a/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift @@ -212,6 +212,56 @@ struct BookmarkList_Tests { #expect(updatedState.first { $0.id == "user-1-activity-2" }?.activity.text == "Test activity content") } + @Test func bookmarkUpdatedEventRemovesBookmarkWhenNoLongerMatchingQuery() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarksResponse.dummy( + bookmarks: [ + .dummy( + activity: .dummy(id: "activity-1"), + folder: .dummy(id: "folder-1", name: "Test Folder"), + updatedAt: .fixed(offset: 0), + user: .dummy(id: "user-1") + ) + ], + next: nil + ) + ] + ) + ) + let bookmarkList = client.bookmarkList( + for: BookmarksQuery( + filter: .less(.updatedAt, Date.fixed(offset: 1)) + ) + ) + try await bookmarkList.get() + + // Verify initial state has the bookmark that matches the filter + let initialBookmarks = await bookmarkList.state.bookmarks + #expect(initialBookmarks.count == 1) + #expect(initialBookmarks.first?.id == "user-1-activity-1") + #expect(initialBookmarks.first?.updatedAt == .fixed(offset: 0)) + + // Send bookmark updated event where the updatedAt changes to a later time + // This should cause the bookmark to no longer match the query filter + await client.eventsMiddleware.sendEvent( + BookmarkUpdatedEvent.dummy( + bookmark: .dummy( + activity: .dummy(id: "activity-1", text: "Updated activity content"), + folder: .dummy(id: "folder-1", name: "Test Folder"), + updatedAt: .fixed(offset: 2), + user: .dummy(id: "user-1") + ), + fid: "user:test" + ) + ) + + // Bookmark should be removed since it no longer matches the updatedAt filter + let bookmarksAfterUpdate = await bookmarkList.state.bookmarks + #expect(bookmarksAfterUpdate.isEmpty) + } + // MARK: - Helper Methods private func defaultClientWithBookmarkResponses( diff --git a/Tests/StreamFeedsTests/StateLayer/CommentList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/CommentList_Tests.swift index 71391ef8..fc288edf 100644 --- a/Tests/StreamFeedsTests/StateLayer/CommentList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/CommentList_Tests.swift @@ -390,6 +390,41 @@ struct CommentList_Tests { let comment = try #require(comments.first) #expect(comment.user.name == "New Name") } + + @Test func commentUpdatedEventRemovesCommentWhenNoLongerMatchingQuery() async throws { + let client = defaultClient( + comments: [.dummy(id: "comment-1", objectId: Self.activityId, objectType: "activity", user: .dummy(id: "user-123"))] + ) + let commentList = client.commentList( + for: CommentsQuery( + filter: .and([ + .equal(.objectId, Self.activityId), + .equal(.objectType, "activity"), + .equal(.userId, "user-123") + ]) + ) + ) + try await commentList.get() + + // Verify initial state has the comment that matches the filter + let initialComments = await commentList.state.comments + #expect(initialComments.count == 1) + #expect(initialComments.first?.id == "comment-1") + #expect(initialComments.first?.user.id == "user-123") + + // Send comment updated event where the user changes to someone else + // This should cause the comment to no longer match the query filter + await client.eventsMiddleware.sendEvent( + CommentUpdatedEvent.dummy( + comment: .dummy(id: "comment-1", objectId: Self.activityId, objectType: "activity", text: "Updated text", user: .dummy(id: "user-other")), + fid: "user:test" + ) + ) + + // Comment should be removed since it no longer matches the userId filter + let commentsAfterUpdate = await commentList.state.comments + #expect(commentsAfterUpdate.isEmpty) + } // MARK: - diff --git a/Tests/StreamFeedsTests/StateLayer/CommentReactionList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/CommentReactionList_Tests.swift index 11bf464a..e60ba508 100644 --- a/Tests/StreamFeedsTests/StateLayer/CommentReactionList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/CommentReactionList_Tests.swift @@ -186,7 +186,7 @@ struct CommentReactionList_Tests { let reaction = await reactionList.state.reactions.first #expect(reaction?.type == "like") } - + // MARK: - private func defaultClient( diff --git a/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift index 7d18dd35..0e6b6d55 100644 --- a/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Combine import StreamCore @testable import StreamFeeds import Testing @@ -144,6 +145,93 @@ struct FeedList_Tests { #expect(updatedState.first { $0.id == "feed-2" }?.name == "Second Feed") } + @Test func feedUpdatedEventRemovesFeedWhenNoLongerMatchingQuery() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads([ + QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()) + ], + next: nil + ) + ]) + ) + let feedList = client.feedList( + for: FeedsQuery( + filter: .equal(.name, "First Feed") + ) + ) + try await feedList.get() + + // Verify initial state has the feed that matches the filter + let initialFeeds = await feedList.state.feeds + #expect(initialFeeds.count == 1) + #expect(initialFeeds.first?.id == "feed-1") + #expect(initialFeeds.first?.name == "First Feed") + + // Send feed updated event where the name changes to something that doesn't match the filter + // This should cause the feed to no longer match the query filter + await client.eventsMiddleware.sendEvent( + FeedUpdatedEvent.dummy( + feed: .dummy(id: "feed-1", name: "Updated Feed Name", createdAt: Date.fixed()), + fid: "user:test" + ) + ) + + // Feed should be removed since it no longer matches the name filter + let feedsAfterUpdate = await feedList.state.feeds + #expect(feedsAfterUpdate.isEmpty) + } + + @Test @MainActor func feedAddedEventWithMembersFilterAndZeroRefetchDelayTriggersImmediateRefetch() async throws { + let client = defaultClientWithFeedsResponses([ + // Additional response for the refetch + QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()), + .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed(offset: 1)), + .dummy(id: "feed-3", name: "New Feed", createdAt: Date.fixed(offset: 2)) + ], + next: nil + ) + ]) + + // Create FeedList with refetchDelay = 0 and members filter (which cannot be filtered locally) + let feedList = FeedList( + query: FeedsQuery( + filter: .in(.members, ["user:member1", "user:member2"]) + ), + client: client, + refetchDelay: 0 + ) + + // Initial load + try await feedList.get() + let initialState = feedList.state.feeds + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["feed-1", "feed-2"]) + + let disposableBag = DisposableBag() + // Send feed added event - this should trigger immediate refetch + await client.eventsMiddleware.sendEvent( + FeedCreatedEvent.dummy( + feed: .dummy(id: "feed-3", name: "New Feed from Web Socket Event Which Should Not Be Added", createdAt: Date.fixed(offset: 2)), + fid: "user:test" + ) + ) + await withCheckedContinuation { continuation in + feedList.state.$feeds + .dropFirst() + .sink { feeds in + let ids = feeds.map(\.name) + #expect(ids == ["First Feed", "Second Feed", "New Feed"]) + continuation.resume() + } + .store(in: disposableBag) + } + disposableBag.removeAll() + } + // MARK: - Helper Methods private func defaultClientWithFeedsResponses( @@ -155,7 +243,7 @@ struct FeedList_Tests { QueryFeedsResponse.dummy( feeds: [ .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()), - .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed()) + .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed(offset: 1)) ], next: "next-cursor" ) diff --git a/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift index 6bd1d255..ea4dc4cb 100644 --- a/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift @@ -123,6 +123,44 @@ struct FollowList_Tests { let result = await followList.state.follows.map(\.id) #expect(result == []) // Follow should be removed } + + @Test func feedFollowUpdatedEventRemovesFollowWhenNoLongerMatchingQuery() async throws { + let client = defaultClient( + follows: [.dummy( + sourceFeed: .dummy(feed: "user:current-user-id"), + status: .accepted, + targetFeed: .dummy(feed: "user:user-1") + )] + ) + let followList = client.followList( + for: FollowsQuery(filter: .equal(.status, FollowStatus.accepted.rawValue)) + ) + try await followList.get() + + // Verify initial state has the follow that matches the filter + let initialFollows = await followList.state.follows + #expect(initialFollows.count == 1) + #expect(initialFollows.first?.id == "user:current-user-id-user:user-1") + #expect(initialFollows.first?.status == .accepted) + + // Send follow updated event where the status changes to something that doesn't match the filter + // This should cause the follow to no longer match the query filter + await client.eventsMiddleware.sendEvent( + FollowUpdatedEvent.dummy( + follow: .dummy( + sourceFeed: .dummy(feed: "user:current-user-id"), + status: .rejected, + targetFeed: .dummy(feed: "user:user-1"), + updatedAt: .fixed(offset: 1) + ), + fid: "user:test" + ) + ) + + // Follow should be removed since it no longer matches the status filter + let followsAfterUpdate = await followList.state.follows + #expect(followsAfterUpdate.isEmpty) + } // MARK: - diff --git a/Tests/StreamFeedsTests/StateLayer/MemberList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/MemberList_Tests.swift index f20c1aa1..1b98a346 100644 --- a/Tests/StreamFeedsTests/StateLayer/MemberList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/MemberList_Tests.swift @@ -188,6 +188,89 @@ struct MemberList_Tests { #expect(updatedState.map(\.id) == ["member-1", "member-2"]) } + @Test func feedMemberUpdatedEventRemovesMemberWhenNoLongerMatchingQuery() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads([ + QueryFeedMembersResponse.dummy( + members: [ + .dummy(user: .dummy(id: "member-1", name: "First Member"), role: "admin") + ], + next: nil + ) + ]) + ) + let memberList = client.memberList( + for: MembersQuery( + feed: FeedId(rawValue: "user:test"), + filter: .equal(.role, "admin") + ) + ) + try await memberList.get() + + // Verify initial state has the member that matches the filter + let initialMembers = await memberList.state.members + #expect(initialMembers.count == 1) + #expect(initialMembers.first?.id == "member-1") + #expect(initialMembers.first?.role == "admin") + + // Send member updated event where the role changes to something that doesn't match the filter + // This should cause the member to no longer match the query filter + await client.eventsMiddleware.sendEvent( + FeedMemberUpdatedEvent.dummy( + fid: "user:test", + member: .dummy(user: .dummy(id: "member-1", name: "First Member"), role: "member") + ) + ) + + // Member should be removed since it no longer matches the role filter + let membersAfterUpdate = await memberList.state.members + #expect(membersAfterUpdate.isEmpty) + } + + @Test func feedMemberBatchUpdateEventRemovesMembersWhenNoLongerMatchingQuery() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads([ + QueryFeedMembersResponse.dummy( + members: [ + .dummy(user: .dummy(id: "member-1", name: "First Member"), role: "admin") + ], + next: nil + ) + ]) + ) + let memberList = client.memberList( + for: MembersQuery( + feed: FeedId(rawValue: "user:test"), + filter: .equal(.role, "admin") + ) + ) + try await memberList.get() + + // Verify initial state has the member that matches the filter + let initialMembers = await memberList.state.members + #expect(initialMembers.count == 1) + #expect(initialMembers.first?.id == "member-1") + #expect(initialMembers.first?.role == "admin") + + // Send batch update event where the member role changes to something that doesn't match the filter + // This should cause the member to no longer match the query filter + let updatedResponses: [FeedMemberResponse] = [ + .dummy(user: .dummy(id: "member-1", name: "First Member"), role: "member") + ] + let updates = ModelUpdates( + added: [], + removedIds: [], + updated: updatedResponses.map { $0.toModel() } + ) + await client.stateLayerEventPublisher.sendEvent( + .feedMemberBatchUpdate(updates, FeedId(rawValue: "user:test")) + ) + + // Member should be removed since it no longer matches the role filter + let membersAfterUpdate = await memberList.state.members + #expect(membersAfterUpdate.isEmpty) + } + // MARK: - Helper Methods private func defaultClientWithMemberResponses( diff --git a/Tests/StreamFeedsTests/StateLayer/PollList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/PollList_Tests.swift index 94e7cfa5..bd8b8f4a 100644 --- a/Tests/StreamFeedsTests/StateLayer/PollList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/PollList_Tests.swift @@ -158,6 +158,37 @@ struct PollList_Tests { let updatedPoll = await pollList.state.polls.first #expect(updatedPoll?.voteCount == 0) } + + @Test func pollUpdatedEventRemovesPollWhenNoLongerMatchingQuery() async throws { + let client = defaultClient( + polls: [.dummy(id: "poll-1", name: "Test Poll")] + ) + let pollList = client.pollList( + for: PollsQuery( + filter: .equal(.name, "Test Poll") + ) + ) + try await pollList.get() + + // Verify initial state has the poll that matches the filter + let initialPolls = await pollList.state.polls + #expect(initialPolls.count == 1) + #expect(initialPolls.first?.id == "poll-1") + #expect(initialPolls.first?.name == "Test Poll") + + // Send poll updated event where the name changes to something that doesn't match the filter + // This should cause the poll to no longer match the query filter + await client.eventsMiddleware.sendEvent( + PollUpdatedFeedEvent.dummy( + poll: .dummy(id: "poll-1", name: "Updated Poll Name", updatedAt: .fixed(offset: 1)), + fid: "user:test" + ) + ) + + // Poll should be removed since it no longer matches the name filter + let pollsAfterUpdate = await pollList.state.polls + #expect(pollsAfterUpdate.isEmpty) + } // MARK: - diff --git a/Tests/StreamFeedsTests/StateLayer/PollVoteList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/PollVoteList_Tests.swift index d5fb4ae3..9697b7ad 100644 --- a/Tests/StreamFeedsTests/StateLayer/PollVoteList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/PollVoteList_Tests.swift @@ -123,6 +123,45 @@ struct PollVoteList_Tests { let result = await voteList.state.votes.map(\.id) #expect(result == ["vote-1"]) // Should not be affected } + + @Test func pollVoteChangedEventRemovesVoteWhenNoLongerMatchingQuery() async throws { + let client = defaultClient( + votes: [.dummy(id: "vote-1", pollId: Self.pollId, user: .dummy(id: "user-123"))] + ) + let voteList = client.pollVoteList( + for: PollVotesQuery( + pollId: Self.pollId, + filter: .equal(.userId, "user-123") + ) + ) + try await voteList.get() + + // Verify initial state has the vote that matches the filter + let initialVotes = await voteList.state.votes + #expect(initialVotes.count == 1) + #expect(initialVotes.first?.id == "vote-1") + #expect(initialVotes.first?.user?.id == "user-123") + + // Send poll vote changed event where the user changes to someone else + // This should cause the vote to no longer match the query filter + await client.eventsMiddleware.sendEvent( + PollVoteChangedFeedEvent.dummy( + poll: .dummy(id: Self.pollId), + vote: .dummy( + id: "vote-1", + optionId: "option-2", + pollId: Self.pollId, + updatedAt: .fixed(offset: 1), + user: .dummy(id: "user-other") + ), + fid: "user:test" + ) + ) + + // Vote should be removed since it no longer matches the userId filter + let votesAfterUpdate = await voteList.state.votes + #expect(votesAfterUpdate.isEmpty) + } // MARK: - diff --git a/Tests/StreamFeedsTests/TestTools/FeedCreatedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/FeedCreatedEvent+Testing.swift new file mode 100644 index 00000000..a7bd50a2 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/FeedCreatedEvent+Testing.swift @@ -0,0 +1,31 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension FeedCreatedEvent { + static func dummy( + createdAt: Date = .fixed(), + custom: [String: RawJSON] = [:], + feed: FeedResponse = .dummy(), + feedVisibility: String? = nil, + fid: String = "user:test", + members: [FeedMemberResponse] = [], + receivedAt: Date? = nil, + user: UserResponseCommonFields = .dummy() + ) -> FeedCreatedEvent { + FeedCreatedEvent( + createdAt: createdAt, + custom: custom, + feed: feed, + feedVisibility: feedVisibility, + fid: fid, + members: members, + receivedAt: receivedAt, + user: user + ) + } +}