Skip to content
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 11 additions & 7 deletions Sources/StreamFeeds/Extensions/Array+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element, [Element]?>?,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 0 additions & 16 deletions Sources/StreamFeeds/Models/BookmarkFolderData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
20 changes: 0 additions & 20 deletions Sources/StreamFeeds/Models/FeedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
6 changes: 1 addition & 5 deletions Sources/StreamFeeds/StateLayer/ActivityState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
private let commentListState: ActivityCommentListState
let currentUserId: String
Expand Down Expand Up @@ -148,8 +148,4 @@ extension ActivityState {
self.activity = activity
poll = activity?.poll
}

private func access<T>(_ actions: @MainActor (ActivityState) -> T) -> T {
actions(self)
}
}
19 changes: 19 additions & 0 deletions Sources/StreamFeeds/StateLayer/Common/StateAccessing.swift
Original file line number Diff line number Diff line change
@@ -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<T>(_ actions: @MainActor (Self) -> T) -> T
}

extension StateAccessing {
@discardableResult func access<T>(_ actions: @MainActor (Self) -> T) -> T {
actions(self)
}
}
6 changes: 1 addition & 5 deletions Sources/StreamFeeds/StateLayer/FeedState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()
private let currentUserId: String
let memberListState: MemberListState
Expand Down Expand Up @@ -376,10 +376,6 @@ extension FeedState {
}
}

@discardableResult func access<T>(_ actions: @MainActor (FeedState) -> T) -> T {
actions(self)
}

private func updateActivity(_ activityData: ActivityData) {
activities.sortedUpdate(
ofId: activityData.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -225,10 +230,6 @@ extension ActivityCommentListState {
}
}

@discardableResult func access<T>(_ actions: @MainActor (ActivityCommentListState) -> T) -> T {
actions(self)
}

func didPaginate(with response: PaginationResult<ThreadedCommentData>) {
pagination = response.pagination
comments = comments.sortedMerge(response.models, sorting: CommentsSort.areInIncreasingOrder(sortingKey))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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)
}
Comment on lines +93 to +97
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g. update event changes the data in a way it does not match the query anymore

}
case .activityDeleted(let activityId, _):
await self?.access { state in
Expand Down Expand Up @@ -221,10 +225,6 @@ extension ActivityListState {
}
}

@discardableResult func access<T>(_ actions: @MainActor (ActivityListState) -> T) -> T {
actions(self)
}

func didPaginate(
with response: PaginationResult<ActivityData>,
for queryConfig: QueryConfiguration<ActivitiesFilter, ActivitiesSortField>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -132,10 +137,6 @@ extension ActivityReactionListState {
}
}

@discardableResult func access<T>(_ actions: @MainActor (ActivityReactionListState) -> T) -> T {
actions(self)
}

func didPaginate(
with response: PaginationResult<FeedsReactionData>,
for queryConfig: QueryConfiguration<ActivityReactionsFilter, ActivityReactionsSortField>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -42,26 +42,31 @@ 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):
await self?.access { state in
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
}
}
}

@discardableResult func access<T>(_ actions: @MainActor (BookmarkFolderListState) -> T) -> T {
actions(self)
}

func didPaginate(
with response: PaginationResult<BookmarkFolderData>,
for queryConfig: QueryConfiguration<BookmarkFoldersFilter, BookmarkFoldersSortField>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -75,10 +84,6 @@ extension BookmarkListState {
bookmarks[index] = bookmark
}

func access<T>(_ actions: @MainActor (BookmarkListState) -> T) -> T {
actions(self)
}

func didPaginate(
with response: PaginationResult<BookmarkData>,
for queryConfig: QueryConfiguration<BookmarksFilter, BookmarksSortField>
Expand Down
Loading