diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml index 25d7554..6b31ef0 100644 --- a/.github/workflows/cron-checks.yml +++ b/.github/workflows/cron-checks.yml @@ -21,6 +21,9 @@ jobs: strategy: matrix: include: + - ios: "26.0" + device: "iPhone 17 Pro" + setup_runtime: false - ios: "18.5" device: "iPhone 16 Pro" setup_runtime: false @@ -36,7 +39,7 @@ jobs: fail-fast: false runs-on: macos-15 env: - XCODE_VERSION: "16.4" + XCODE_VERSION: "26.0.1" steps: - uses: actions/checkout@v4.1.1 - uses: ./.github/actions/bootstrap @@ -70,6 +73,8 @@ jobs: strategy: matrix: include: + - xcode: 26.0.1 # swift 6.2 + os: macos-15 - xcode: 16.4 # swift 6.1 os: macos-15 - xcode: 16.1 # swift 6.0 diff --git a/.github/workflows/sdk-size-metrics.yml b/.github/workflows/sdk-size-metrics.yml index a05407b..e71245a 100644 --- a/.github/workflows/sdk-size-metrics.yml +++ b/.github/workflows/sdk-size-metrics.yml @@ -18,6 +18,7 @@ jobs: runs-on: macos-15 env: GITHUB_TOKEN: '${{ secrets.CI_BOT_GITHUB_TOKEN }}' + GITHUB_PR_NUM: ${{ github.event.pull_request.number }} steps: - name: Connect Bot uses: webfactory/ssh-agent@v0.7.0 @@ -28,10 +29,13 @@ jobs: - uses: ./.github/actions/bootstrap - - name: Run SDK Size Metrics + - name: Run General SDK Size Metrics run: bundle exec fastlane show_frameworks_sizes timeout-minutes: 30 env: - GITHUB_PR_NUM: ${{ github.event.pull_request.number }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} + + - name: Run Detailed SDK Size Metrics + run: bundle exec fastlane size_analyze + timeout-minutes: 30 diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml index 2a45fd2..d97fb32 100644 --- a/.github/workflows/smoke-checks.yml +++ b/.github/workflows/smoke-checks.yml @@ -20,7 +20,7 @@ concurrency: env: HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI - IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.5)" + IOS_SIMULATOR_DEVICE: "iPhone 17 Pro (26.0)" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_PR_NUM: ${{ github.event.pull_request.number }} diff --git a/.gitignore b/.gitignore index 3ea9776..6b1cc57 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ App Thinning Size Report.txt app-thinning.plist *.dmg yeetd-normal.pkg +*LinkMap.txt # Stream Video Buddy video-buddy-server.log diff --git a/.swiftformat b/.swiftformat index e7b95e9..5a46ff3 100644 --- a/.swiftformat +++ b/.swiftformat @@ -2,22 +2,65 @@ --header "\nCopyright © {year} Stream.io Inc. All rights reserved.\n" --swiftversion 6.0 ---ifdef no-indent ---disable redundantType ---disable extensionAccessControl ---disable andOperator ---disable hoistPatternLet ---disable typeSugar ---disable opaqueGenericParameters ---disable genericExtensions ---disable preferForLoop ---disable redundantGet # it removes get async throws from getters +--rules blankLinesAroundMark +--rules blankLinesAtEndOfScope +--rules blankLinesAtStartOfScope +--rules blankLinesBetweenScopes +--rules braces +--rules consecutiveBlankLines +--rules consecutiveSpaces +--rules duplicateImports +--rules elseOnSameLine +--rules emptyBraces +--rules enumNamespaces +--rules fileHeader +--rules indent +--rules initCoderUnavailable +--rules isEmpty +--rules leadingDelimiters +--rules linebreakAtEndOfFile +--rules linebreaks +--rules modifierOrder +--rules numberFormatting +--rules redundantBackticks +--rules redundantBreak +--rules redundantExtensionACL +--rules redundantFileprivate +--rules redundantLet +--rules redundantLetError +--rules redundantNilInit +--rules redundantObjc +--rules redundantPattern +--rules redundantRawValues +--rules redundantVoidReturnType +--rules semicolons +--rules sortImports +--rules spaceAroundBraces +--rules spaceAroundBrackets +--rules spaceAroundComments +--rules spaceAroundGenerics +--rules spaceAroundOperators +--rules spaceAroundParens +--rules spaceInsideBraces +--rules spaceInsideBrackets +--rules spaceInsideComments +--rules spaceInsideGenerics +--rules spaceInsideParens +--rules strongOutlets +--rules strongifiedSelf +--rules todos +--rules trailingCommas +--rules trailingSpace +--rules unusedArguments +--rules void +--rules wrap +--rules wrapArguments +--rules wrapAttributes +--rules yodaConditions -# Rules inferred from Swift Standard Library: ---disable anyObjectProtocol, wrapMultilineStatementBraces +# Configuration for enabled rules +--ifdef no-indent --indent 4 ---enable isEmpty ---disable redundantParens # it generates mistakes for e.g. "if (a || b), let x = ... {}" --semicolons inline --nospaceoperators ..., ..< # what about ==, +=? --commas inline @@ -35,3 +78,6 @@ --wraparguments before-first --wrapparameters before-first --wrapcollections before-first + +# Exclude paths +--exclude Sources/StreamFeeds/Generated,Sources/StreamFeeds/generated,vendor/bundle,Pods,spm_cache,derived_data,.build diff --git a/.swiftlint.yml b/.swiftlint.yml index 3475860..76f3b5e 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,17 +4,12 @@ excluded: - .ruby-lsp - .swiftpm - derived_data - - fastlane - - Package.swift - - Scripts - Sources/StreamFeeds/Generated - Sources/StreamFeeds/generated - spm_cache - - vendor - vendor/bundle only_rules: - # Currently enabled autocorrectable rules - attribute_name_spacing - closing_brace - colon @@ -26,7 +21,7 @@ only_rules: - empty_enum_arguments - empty_parameters - empty_parentheses_with_trailing_closure - - explicit_init + - file_name_no_space - joined_default_parameter - leading_whitespace - legacy_cggeometry_functions @@ -34,6 +29,7 @@ only_rules: - legacy_constructor - legacy_nsgeometry_functions - mark + - multiline_arguments - no_space_in_method_call - prefer_type_checking - private_over_fileprivate @@ -50,7 +46,6 @@ only_rules: - trailing_comma - trailing_newline - trailing_semicolon - - trailing_whitespace - unneeded_break_in_switch - unneeded_override - unused_closure_parameter @@ -59,8 +54,11 @@ only_rules: - vertical_whitespace - void_return -trailing_whitespace: - ignores_empty_lines: true +multiline_arguments: + only_enforce_after_first_closure_on_first_line: true + +file_name_no_space: + severity: error custom_rules: coredata_date_forbidden: diff --git a/CHANGELOG.md b/CHANGELOG.md index cff0e07..9c95aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### 🔄 Changed +# [0.5.0](https://github.com/GetStream/stream-feeds-swift/releases/tag/0.5.0) +_November 19, 2025_ + +### ✅ Added +- Update local state when using activity batch operations in `FeedsClient` [#52](https://github.com/GetStream/stream-feeds-swift/pull/52) +- Keep `FeedData.ownCapabilities` up to date in every model when handling web-socket events [#51](https://github.com/GetStream/stream-feeds-swift/pull/51) +- Add filtering and sorting keys: [#53](https://github.com/GetStream/stream-feeds-swift/pull/53) + - `ActivitiesFilterField.feeds` + - `ActivitiesFilterField.interestTags` + - `ActivitiesFilterField.near` + - `ActivitiesFilterField.withinBounds` + - `ModerationConfigsSortField.team` +### 🐞 Fixed +- Fix remote key for `ModerationConfigsSortField.key` [#53](https://github.com/GetStream/stream-feeds-swift/pull/53) +### 🔄 Changed +- Rename `ActivitiesFilterField.type` to `ActivitiesFilterField.activityType` [#53](https://github.com/GetStream/stream-feeds-swift/pull/53) + # [0.4.0](https://github.com/GetStream/stream-feeds-swift/releases/tag/0.4.0) _September 25, 2025_ diff --git a/DemoApp/FeedsView/FeedsListView.swift b/DemoApp/FeedsView/FeedsListView.swift index f7202b3..edb9ee3 100644 --- a/DemoApp/FeedsView/FeedsListView.swift +++ b/DemoApp/FeedsView/FeedsListView.swift @@ -149,29 +149,34 @@ struct FeedsListView: View { } .disabled(updatedActivityText.trimmed.isEmpty) } - .alert("Delete Activity", isPresented: .init( - get: { activityToDelete != nil }, - set: { if !$0 { activityToDelete = nil } } - ), actions: { - Button("Cancel", role: .cancel) { - activityToDelete = nil - } - Button("Delete", role: .destructive) { - if let activity = activityToDelete { - Task { - do { - _ = try await feed.deleteActivity(id: activity.id) - activityToDelete = nil - } catch { - log.error("Error deleting an activity \(error)") - bannerError = error + .alert( + "Delete Activity", + isPresented: .init( + get: { activityToDelete != nil }, + set: { if !$0 { activityToDelete = nil } } + ), + actions: { + Button("Cancel", role: .cancel) { + activityToDelete = nil + } + Button("Delete", role: .destructive) { + if let activity = activityToDelete { + Task { + do { + _ = try await feed.deleteActivity(id: activity.id) + activityToDelete = nil + } catch { + log.error("Error deleting an activity \(error)") + bannerError = error + } } } } + }, + message: { + Text("Are you sure you want to delete this activity?") } - }, message: { - Text("Are you sure you want to delete this activity?") - }) + ) } func refresh() async { @@ -206,7 +211,7 @@ struct ActivityView: View { @State var selectedAttachment: Attachment? let user: UserData - let ownCapabilities: [FeedOwnCapability] + let ownCapabilities: Set let text: String var attachments: [Attachment]? var activity: ActivityData diff --git a/DemoApp/Info.plist b/DemoApp/Info.plist index cd5e178..c6ef2ae 100644 --- a/DemoApp/Info.plist +++ b/DemoApp/Info.plist @@ -57,10 +57,7 @@ UISupportedInterfaceOrientations - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown diff --git a/Gemfile.lock b/Gemfile.lock index 909de16..390d167 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -206,9 +206,11 @@ GEM bundler fastlane pry - fastlane-plugin-stream_actions (0.3.90) + fastlane-plugin-stream_actions (0.3.101) xctest_list (= 1.2.1) fastlane-plugin-versioning (0.7.1) + fastlane-plugin-xcsize (1.2.0) + xcsize (= 1.2.0) fastlane-sirp (1.0.0) sysrandom (~> 1.0) ffi (1.17.2) @@ -388,6 +390,8 @@ GEM rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) + xcsize (1.2.0) + commander (>= 4.6, < 6.0) xctest_list (1.2.1) PLATFORMS @@ -399,8 +403,9 @@ DEPENDENCIES danger-commit_lint fastlane fastlane-plugin-lizard - fastlane-plugin-stream_actions (= 0.3.90) + fastlane-plugin-stream_actions (= 0.3.101) fastlane-plugin-versioning + fastlane-plugin-xcsize (= 1.2.0) json lefthook plist diff --git a/Githubfile b/Githubfile index 59a298a..234bff1 100644 --- a/Githubfile +++ b/Githubfile @@ -3,10 +3,9 @@ export ALLURECTL_VERSION='2.16.0' export XCRESULTS_VERSION='1.19.1' export YEETD_VERSION='1.0' -export MINT_VERSION='0.17.5' export SONAR_VERSION='6.2.1.4610' export IPSW_VERSION='3.1.592' export INTERFACE_ANALYZER_VERSION='1.0.7' export SWIFT_LINT_VERSION='0.59.1' -export SWIFT_FORMAT_VERSION='0.56.4' +export SWIFT_FORMAT_VERSION='0.58.2' export SWIFT_GEN_VERSION='6.5.1' diff --git a/Package.swift b/Package.swift index 4701430..2c5b3c5 100644 --- a/Package.swift +++ b/Package.swift @@ -14,13 +14,15 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/GetStream/stream-core-swift.git", exact: "0.1.0") + .package(url: "https://github.com/GetStream/stream-core-swift.git", exact: "0.6.0") ], targets: [ .target( name: "StreamFeeds", dependencies: [ - .product(name: "StreamCore", package: "stream-core-swift") + .product(name: "StreamAttachments", package: "stream-core-swift"), + .product(name: "StreamCore", package: "stream-core-swift"), + .product(name: "StreamOpenAPI", package: "stream-core-swift") ] ), .testTarget( diff --git a/Sources/StreamFeeds/Extensions/Array+Extensions.swift b/Sources/StreamFeeds/Extensions/Array+Extensions.swift index 1ec0693..700fcf2 100644 --- a/Sources/StreamFeeds/Extensions/Array+Extensions.swift +++ b/Sources/StreamFeeds/Extensions/Array+Extensions.swift @@ -42,9 +42,15 @@ extension Array { /// /// - Parameter ids: Ids of elements to remove. mutating func remove(byIds ids: [Element.ID]) where Element: Identifiable { + remove(byIds: Set(ids)) + } + + /// Removes elements from the non-sorted array based on ID. + /// + /// - Parameter ids: Ids of elements to remove. + mutating func remove(byIds ids: Set) where Element: Identifiable { guard !ids.isEmpty else { return } - let lookup = Set(ids) - removeAll(where: { lookup.contains($0.id) }) + removeAll(where: { ids.contains($0.id) }) } /// Replaces an element from the non-sorted array based on its ID. @@ -273,6 +279,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 +302,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 +344,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/Extensions/CoreLocation+Extensions.swift b/Sources/StreamFeeds/Extensions/CoreLocation+Extensions.swift new file mode 100644 index 0000000..9855c79 --- /dev/null +++ b/Sources/StreamFeeds/Extensions/CoreLocation+Extensions.swift @@ -0,0 +1,14 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import CoreLocation +import Foundation + +extension CLLocationCoordinate2D: @retroactive Equatable { + public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { + let epsilon = 1e-7 // ~1cm precision + return abs(lhs.latitude - rhs.latitude) < epsilon && + abs(lhs.longitude - rhs.longitude) < epsilon + } +} diff --git a/Sources/StreamFeeds/Extensions/Dictionary+Extensions.swift b/Sources/StreamFeeds/Extensions/Dictionary+Extensions.swift new file mode 100644 index 0000000..b52d866 --- /dev/null +++ b/Sources/StreamFeeds/Extensions/Dictionary+Extensions.swift @@ -0,0 +1,12 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension Dictionary { + func contains(_ key: Key?) -> Bool { + guard let key else { return false } + return self[key] != nil + } +} diff --git a/Sources/StreamFeeds/Extensions/JSON+Extensions.swift b/Sources/StreamFeeds/Extensions/JSON+Extensions.swift new file mode 100644 index 0000000..9716fc2 --- /dev/null +++ b/Sources/StreamFeeds/Extensions/JSON+Extensions.swift @@ -0,0 +1,13 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +extension JSONDecoder { + static var `default`: JSONDecoder { .streamCore } +} + +extension JSONEncoder { + static var `default`: JSONEncoder { .streamCore } +} diff --git a/Sources/StreamFeeds/Extensions/StreamCore+Extensions.swift b/Sources/StreamFeeds/Extensions/StreamCore+Extensions.swift new file mode 100644 index 0000000..40b99e5 --- /dev/null +++ b/Sources/StreamFeeds/Extensions/StreamCore+Extensions.swift @@ -0,0 +1,6 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +@_exported import StreamAttachments +@_exported import StreamCore diff --git a/Sources/StreamFeeds/FeedsClient+Connection.swift b/Sources/StreamFeeds/FeedsClient+Connection.swift index 1974321..e8ccdc3 100644 --- a/Sources/StreamFeeds/FeedsClient+Connection.swift +++ b/Sources/StreamFeeds/FeedsClient+Connection.swift @@ -3,7 +3,6 @@ // import Foundation -import StreamCore extension FeedsClient { /// When initializing we perform an automatic connection attempt. @@ -93,7 +92,7 @@ extension FeedsClient { eventDecoder: JSONEventDecoder(), eventNotificationCenter: eventNotificationCenter, webSocketClientType: .coordinator, - connectURL: url + connectRequest: URLRequest(url: url) ) webSocketClient.connectionStateDelegate = self diff --git a/Sources/StreamFeeds/FeedsClient.swift b/Sources/StreamFeeds/FeedsClient.swift index e42e854..8e2d079 100644 --- a/Sources/StreamFeeds/FeedsClient.swift +++ b/Sources/StreamFeeds/FeedsClient.swift @@ -4,7 +4,6 @@ import Combine import Foundation -@preconcurrency import StreamCore public final class FeedsClient: Sendable { public let apiKey: APIKey @@ -29,7 +28,7 @@ public final class FeedsClient: Sendable { let webSocketClient = AllocatedUnfairLock(nil) let eventsMiddleware = WSEventsMiddleware() - let eventNotificationCenter: EventNotificationCenter + let eventNotificationCenter: DefaultEventNotificationCenter let stateLayerEventPublisher = StateLayerEventPublisher() let activitiesRepository: ActivitiesRepository @@ -40,6 +39,7 @@ public final class FeedsClient: Sendable { let feedsRepository: FeedsRepository let moderationRepository: ModerationRepository let pollsRepository: PollsRepository + let ownCapabilitiesRepository: OwnCapabilitiesRepository private let _token: AllocatedUnfairLock private let _userAuth = AllocatedUnfairLock(nil) @@ -110,7 +110,7 @@ public final class FeedsClient: Sendable { transport: apiTransport, middlewares: [defaultParams] ) - eventNotificationCenter = EventNotificationCenter() + eventNotificationCenter = DefaultEventNotificationCenter() requestEncoder = DefaultRequestEncoder( baseURL: URL(string: basePath)!, apiKey: apiKey @@ -128,14 +128,24 @@ public final class FeedsClient: Sendable { commentsRepository = CommentsRepository(apiClient: apiClient) devicesRepository = DevicesRepository(devicesClient: devicesClient) feedsRepository = FeedsRepository(apiClient: apiClient) - pollsRepository = PollsRepository(apiClient: apiClient) moderationRepository = ModerationRepository(apiClient: apiClient) + ownCapabilitiesRepository = OwnCapabilitiesRepository(apiClient: apiClient) + pollsRepository = PollsRepository(apiClient: apiClient) moderation = Moderation(apiClient: apiClient) eventsMiddleware.add(subscriber: self) eventsMiddleware.add(subscriber: stateLayerEventPublisher) eventNotificationCenter.add(middlewares: [eventsMiddleware]) + + stateLayerEventPublisher.addMiddlewares( + [ + OwnCapabilitiesStateLayerEventMiddleware( + ownCapabilitiesRepository: ownCapabilitiesRepository, + sendEvent: { [weak stateLayerEventPublisher] in await stateLayerEventPublisher?.sendEvent($0) } + ) + ] + ) } // MARK: - Connecting the User @@ -375,14 +385,20 @@ public final class FeedsClient: Sendable { ActivityReactionList(query: query, client: self) } - /// Adds a new activity to the specified feeds. + // MARK: - Activity Batch Operations + + /// Adds a new activity to one or multiple feeds. /// - /// - Parameter request: The request containing the activity data to add + /// - Parameter request: The request containing the activity data to add and the list of feeds /// - Returns: A response containing the created activity /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult - public func addActivity(request: AddActivityRequest) async throws -> AddActivityResponse { - try await apiClient.addActivity(addActivityRequest: request) + public func addActivity(request: AddActivityRequest) async throws -> ActivityData { + let activityData = try await activitiesRepository.addActivity(request: request) + for feed in request.feeds.map(FeedId.init) { + await stateLayerEventPublisher.sendEvent(.activityAdded(activityData, feed)) + } + return activityData } /// Upserts (inserts or updates) multiple activities. @@ -392,7 +408,16 @@ public final class FeedsClient: Sendable { /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult public func upsertActivities(_ activities: [ActivityRequest]) async throws -> [ActivityData] { - try await activitiesRepository.upsertActivities(activities) + let activities = try await activitiesRepository.upsertActivities(activities) + let updates = activities.reduce(into: ModelUpdates()) { partialResult, activityData in + if activityData.updatedAt.timeIntervalSince(activityData.createdAt) > 0.1 || activityData.editedAt != nil { + partialResult.updated.append(activityData) + } else { + partialResult.added.append(activityData) + } + } + await stateLayerEventPublisher.sendEvent(.activityBatchUpdate(updates)) + return activities } /// Deletes multiple activities from the specified feeds. @@ -401,8 +426,10 @@ public final class FeedsClient: Sendable { /// - Returns: A response confirming the deletion of activities /// - Throws: `APIError` if the network request fails or the server returns an error @discardableResult - public func deleteActivities(request: DeleteActivitiesRequest) async throws -> DeleteActivitiesResponse { - try await apiClient.deleteActivities(deleteActivitiesRequest: request) + public func deleteActivities(request: DeleteActivitiesRequest) async throws -> Set { + let response = try await apiClient.deleteActivities(deleteActivitiesRequest: request) + await stateLayerEventPublisher.sendEvent(.activityBatchUpdate(.init(added: [], removedIds: response.deletedIds, updated: []))) + return Set(response.deletedIds) } // MARK: - Bookmark Lists diff --git a/Sources/StreamFeeds/Info.plist b/Sources/StreamFeeds/Info.plist index 075d3c6..ed3e083 100644 --- a/Sources/StreamFeeds/Info.plist +++ b/Sources/StreamFeeds/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.4.0 + 0.5.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSHumanReadableCopyright diff --git a/Sources/StreamFeeds/Models/ActivityData.swift b/Sources/StreamFeeds/Models/ActivityData.swift index bd16beb..f5aca4b 100644 --- a/Sources/StreamFeeds/Models/ActivityData.swift +++ b/Sources/StreamFeeds/Models/ActivityData.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import CoreLocation import Foundation import StreamCore @@ -11,17 +12,18 @@ public struct ActivityData: Identifiable, Equatable, Sendable { public private(set) var commentCount: Int public private(set) var comments: [CommentData] public let createdAt: Date - public let currentFeed: FeedData? + public private(set) var currentFeed: FeedData? public let custom: [String: RawJSON] public let deletedAt: Date? public let editedAt: Date? public let expiresAt: Date? public let feeds: [String] public let filterTags: [String] + public let hidden: Bool public let id: String public let interestTags: [String] public private(set) var latestReactions: [FeedsReactionData] - public let location: ActivityLocation? + public let location: CLLocationCoordinate2D? public let mentionedUsers: [UserData] public let moderation: ModerationV2Response? public let notificationContext: NotificationContext? @@ -106,6 +108,12 @@ extension ActivityData { } } + mutating func deleteComment(_ comment: CommentData) { + if comments.remove(byId: comment.id) { + commentCount = max(0, commentCount - 1) + } + } + mutating func updateComment(_ incomingData: CommentData) { comments.updateFirstElement(where: { $0.id == incomingData.id }, changes: { $0.merge(with: incomingData) }) } @@ -131,48 +139,22 @@ extension ActivityData { ) } - // MARK: - - - mutating func deleteComment(_ comment: CommentData) { - if comments.remove(byId: comment.id) { - commentCount = max(0, commentCount - 1) - } - } - - mutating func addBookmark(_ bookmark: BookmarkData, currentUserId: String) { - if bookmark.user.id == currentUserId { - if ownBookmarks.insert(byId: bookmark) { - bookmarkCount += 1 - } - } else { - bookmarkCount += 1 - } - } + // MARK: - Current Feed Capabilities - mutating func deleteBookmark(_ bookmark: BookmarkData, currentUserId: String) { - if bookmark.user.id == currentUserId { - if ownBookmarks.remove(byId: bookmark.id) { - bookmarkCount = max(0, bookmarkCount - 1) - } - } else { - bookmarkCount = max(0, bookmarkCount - 1) - } + mutating func setFeedOwnCapabilities(_ capabilities: Set) { + currentFeed?.setOwnCapabilities(capabilities) } - mutating func addReaction(_ reaction: FeedsReactionData, currentUserId: String) { - FeedsReactionData.updateByAdding(reaction: reaction, to: &latestReactions, reactionGroups: &reactionGroups) - reactionCount = reactionGroups.values.reduce(0) { $0 + $1.count } - if reaction.user.id == currentUserId { - ownReactions.insert(byId: reaction) - } + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + guard let feedId = currentFeed?.feed else { return } + guard let capabilities = capabilitiesMap[feedId] else { return } + currentFeed?.setOwnCapabilities(capabilities) } - mutating func removeReaction(_ reaction: FeedsReactionData, currentUserId: String) { - FeedsReactionData.updateByRemoving(reaction: reaction, from: &latestReactions, reactionGroups: &reactionGroups) - reactionCount = reactionGroups.values.reduce(0) { $0 + $1.count } - if reaction.user.id == currentUserId { - ownReactions.remove(byId: reaction.id) - } + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> ActivityData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated } } @@ -193,10 +175,11 @@ extension ActivityResponse { expiresAt: expiresAt, feeds: feeds, filterTags: filterTags, + hidden: hidden ?? false, id: id, interestTags: interestTags, latestReactions: latestReactions.map { $0.toModel() }, - location: location, + location: location.flatMap { CLLocationCoordinate2D(latitude: CLLocationDegrees($0.lat), longitude: CLLocationDegrees($0.lng)) }, mentionedUsers: mentionedUsers.map { $0.toModel() }, moderation: moderation, notificationContext: notificationContext, diff --git a/Sources/StreamFeeds/Models/ActivityPinData.swift b/Sources/StreamFeeds/Models/ActivityPinData.swift index c2d6871..f145c29 100644 --- a/Sources/StreamFeeds/Models/ActivityPinData.swift +++ b/Sources/StreamFeeds/Models/ActivityPinData.swift @@ -7,6 +7,7 @@ import Foundation public struct ActivityPinData: Equatable, Sendable { public internal(set) var activity: ActivityData public let createdAt: Date + public let duration: String? public let feed: FeedId public let updatedAt: Date public let userId: String @@ -25,6 +26,7 @@ extension ActivityPinResponse { ActivityPinData( activity: activity.toModel(), createdAt: createdAt, + duration: nil, // ActivityPinResponse doesn't have duration feed: FeedId(rawValue: feed), updatedAt: updatedAt, userId: user.id @@ -37,6 +39,7 @@ extension PinActivityResponse { ActivityPinData( activity: activity.toModel(), createdAt: createdAt, + duration: duration, feed: FeedId(rawValue: feed), updatedAt: createdAt, // no updatedAt userId: userId diff --git a/Sources/StreamFeeds/Models/AggregatedActivityData.swift b/Sources/StreamFeeds/Models/AggregatedActivityData.swift index 3d6772e..c867f63 100644 --- a/Sources/StreamFeeds/Models/AggregatedActivityData.swift +++ b/Sources/StreamFeeds/Models/AggregatedActivityData.swift @@ -13,6 +13,7 @@ public struct AggregatedActivityData: Identifiable, Equatable, Sendable { public var score: Float public var updatedAt: Date public var userCount: Int + public var userCountTruncated: Bool public var id: String { if let first = activities.first?.id { @@ -32,7 +33,8 @@ extension AggregatedActivityResponse { group: group, score: score, updatedAt: updatedAt, - userCount: userCount + userCount: userCount, + userCountTruncated: userCountTruncated ) } } diff --git a/Sources/StreamFeeds/Models/BookmarkData.swift b/Sources/StreamFeeds/Models/BookmarkData.swift index 6d26e0d..feb8f0a 100644 --- a/Sources/StreamFeeds/Models/BookmarkData.swift +++ b/Sources/StreamFeeds/Models/BookmarkData.swift @@ -6,7 +6,7 @@ import Foundation import StreamCore public struct BookmarkData: Equatable, Sendable { - public let activity: ActivityData + public private(set) var activity: ActivityData public let createdAt: Date public let custom: [String: RawJSON]? public internal(set) var folder: BookmarkFolderData? @@ -16,7 +16,27 @@ public struct BookmarkData: Equatable, Sendable { extension BookmarkData: Identifiable { public var id: String { - activity.id + user.id + "\(user.id)-\(activity.id)" + } +} + +// MARK: - Mutating the Data + +extension BookmarkData { + mutating func merge(with incomingData: ActivityData) { + activity.merge(with: incomingData) + } + + // MARK: - Current Feed Capabilities + + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + activity.mergeFeedOwnCapabilities(from: capabilitiesMap) + } + + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> BookmarkData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated } } diff --git a/Sources/StreamFeeds/Models/BookmarkFolderData.swift b/Sources/StreamFeeds/Models/BookmarkFolderData.swift index fbb006e..5fd00a4 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/CommentData.swift b/Sources/StreamFeeds/Models/CommentData.swift index a2e5045..ba667c2 100644 --- a/Sources/StreamFeeds/Models/CommentData.swift +++ b/Sources/StreamFeeds/Models/CommentData.swift @@ -172,22 +172,6 @@ extension CommentData { ownReactions.replace(byId: reaction) } - mutating func addReaction(_ reaction: FeedsReactionData, currentUserId: String) { - FeedsReactionData.updateByAdding(reaction: reaction, to: &latestReactions, reactionGroups: &reactionGroups) - reactionCount = reactionGroups.values.reduce(0) { $0 + $1.count } - if reaction.user.id == currentUserId { - ownReactions.append(reaction) - } - } - - mutating func removeReaction(_ reaction: FeedsReactionData, currentUserId: String) { - FeedsReactionData.updateByRemoving(reaction: reaction, from: &latestReactions, reactionGroups: &reactionGroups) - reactionCount = reactionGroups.values.reduce(0) { $0 + $1.count } - if reaction.user.id == currentUserId { - ownReactions.remove(byId: reaction.id) - } - } - mutating func updateUser(_ incomingData: UserData) { mentionedUsers.updateAll(where: { $0.id == incomingData.id }, changes: { $0 = incomingData }) user = incomingData diff --git a/Sources/StreamFeeds/Models/FeedData.swift b/Sources/StreamFeeds/Models/FeedData.swift index b3e432b..90df9b6 100644 --- a/Sources/StreamFeeds/Models/FeedData.swift +++ b/Sources/StreamFeeds/Models/FeedData.swift @@ -19,12 +19,41 @@ public struct FeedData: Identifiable, Equatable, Sendable { public let id: String public let memberCount: Int public let name: String + public private(set) var ownCapabilities: Set? + public private(set) var ownFollows: [FollowData]? + public private(set) var ownMembership: FeedMemberData? public let pinCount: Int public let updatedAt: Date public let visibility: String? - public let ownCapabilities: [FeedOwnCapability]? +} + +// MARK: - Mutating the Data + +extension FeedData { + mutating func merge(with incomingData: FeedData) { + let ownCapabilities = ownCapabilities + let ownFollows = ownFollows + let ownMembership = ownMembership + self = incomingData + self.ownCapabilities = incomingData.ownCapabilities ?? ownCapabilities + self.ownFollows = incomingData.ownFollows ?? ownFollows + self.ownMembership = incomingData.ownMembership ?? ownMembership + } + + mutating func setOwnCapabilities(_ capabilities: Set) { + self.ownCapabilities = capabilities + } + + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + guard let capabilities = capabilitiesMap[feed] else { return } + setOwnCapabilities(capabilities) + } - var localFilterData: LocalFilterData? + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> FeedData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated + } } // MARK: - Model Conversions @@ -45,28 +74,12 @@ extension FeedResponse { id: id, memberCount: memberCount, name: name, + ownCapabilities: ownCapabilities.map(Set.init), + ownFollows: ownFollows?.map { $0.toModel() }, + ownMembership: ownMembership?.toModel(), pinCount: pinCount, updatedAt: updatedAt, - visibility: visibility, - ownCapabilities: ownCapabilities + visibility: visibility ) } } - -// 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/Models/FeedMemberData.swift b/Sources/StreamFeeds/Models/FeedMemberData.swift index ac6dcce..7502df9 100644 --- a/Sources/StreamFeeds/Models/FeedMemberData.swift +++ b/Sources/StreamFeeds/Models/FeedMemberData.swift @@ -10,6 +10,7 @@ public struct FeedMemberData: Equatable, Sendable { public let custom: [String: RawJSON]? public let inviteAcceptedAt: Date? public let inviteRejectedAt: Date? + public let membershipLevel: MembershipLevelResponse? public let role: String public let status: FeedMemberStatus public let updatedAt: Date @@ -35,6 +36,7 @@ extension FeedMemberResponse { custom: custom, inviteAcceptedAt: inviteAcceptedAt, inviteRejectedAt: inviteRejectedAt, + membershipLevel: membershipLevel, role: role, status: status, updatedAt: updatedAt, diff --git a/Sources/StreamFeeds/Models/FeedsReactionData.swift b/Sources/StreamFeeds/Models/FeedsReactionData.swift index e02289f..31368e5 100644 --- a/Sources/StreamFeeds/Models/FeedsReactionData.swift +++ b/Sources/StreamFeeds/Models/FeedsReactionData.swift @@ -7,6 +7,7 @@ import StreamCore public struct FeedsReactionData: Equatable, Sendable { public let activityId: String + public let commentId: String? public let createdAt: Date public let custom: [String: RawJSON]? public let type: String @@ -16,40 +17,17 @@ public struct FeedsReactionData: Equatable, Sendable { extension FeedsReactionData: Identifiable { public var id: String { - "\(user.id)-\(type)-\(activityId)" + if let commentId { + "\(user.id)-\(type)-\(commentId)-\(activityId)" + } else { + "\(user.id)-\(type)-\(activityId)" + } } } // MARK: - Mutating the Data extension FeedsReactionData { - static func updateByAdding( - reaction: FeedsReactionData, - to latestReactions: inout [FeedsReactionData], - reactionGroups: inout [String: ReactionGroupData] - ) { - guard latestReactions.insert(byId: reaction) else { return } - var reactionGroup = reactionGroups[reaction.type] ?? ReactionGroupData(count: 1, firstReactionAt: reaction.createdAt, lastReactionAt: reaction.createdAt) - reactionGroup.increment(with: reaction.createdAt) - reactionGroups[reaction.type] = reactionGroup - } - - static func updateByRemoving( - reaction: FeedsReactionData, - from latestReactions: inout [FeedsReactionData], - reactionGroups: inout [String: ReactionGroupData] - ) { - guard latestReactions.remove(byId: reaction.id) else { return } - if var reactionGroup = reactionGroups[reaction.type] { - reactionGroup.decrement(with: reaction.createdAt) - if !reactionGroup.isEmpty { - reactionGroups[reaction.type] = reactionGroup - } else { - reactionGroups.removeValue(forKey: reaction.type) - } - } - } - mutating func updateUser(_ incomingData: UserData) { user = incomingData } @@ -61,6 +39,7 @@ extension FeedsReactionResponse { func toModel() -> FeedsReactionData { FeedsReactionData( activityId: activityId, + commentId: commentId, createdAt: createdAt, custom: custom, type: type, diff --git a/Sources/StreamFeeds/Models/FollowData.swift b/Sources/StreamFeeds/Models/FollowData.swift index 0cbdfdc..f6b4410 100644 --- a/Sources/StreamFeeds/Models/FollowData.swift +++ b/Sources/StreamFeeds/Models/FollowData.swift @@ -12,9 +12,9 @@ public struct FollowData: Equatable, Sendable { public let pushPreference: String public let requestAcceptedAt: Date? public let requestRejectedAt: Date? - public let sourceFeed: FeedData + public private(set) var sourceFeed: FeedData public let status: FollowStatus - public let targetFeed: FeedData + public private(set) var targetFeed: FeedData public let updatedAt: Date var isFollower: Bool { @@ -46,6 +46,27 @@ extension FollowData: Identifiable { } } +// MARK: - Mutating the Data + +extension FollowData { + // MARK: - Current Feed Capabilities + + mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) { + if let capabilities = capabilitiesMap[sourceFeed.feed] { + sourceFeed.setOwnCapabilities(capabilities) + } + if let capabilities = capabilitiesMap[targetFeed.feed] { + targetFeed.setOwnCapabilities(capabilities) + } + } + + func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set]) -> FollowData { + var updated = self + updated.mergeFeedOwnCapabilities(from: capabilitiesMap) + return updated + } +} + // MARK: - Model Conversions extension FollowResponse { diff --git a/Sources/StreamFeeds/Models/ModelUpdates.swift b/Sources/StreamFeeds/Models/ModelUpdates.swift index ba0a6bd..e69859d 100644 --- a/Sources/StreamFeeds/Models/ModelUpdates.swift +++ b/Sources/StreamFeeds/Models/ModelUpdates.swift @@ -5,7 +5,13 @@ import Foundation public struct ModelUpdates: Sendable where Model: Sendable { - public let added: [Model] - public let removedIds: [String] - public let updated: [Model] + public internal(set) var added: [Model] + public internal(set) var removedIds: Set + public internal(set) var updated: [Model] +} + +extension ModelUpdates { + init(added: [Model] = [], removedIds: [String] = [], updated: [Model] = []) { + self.init(added: added, removedIds: Set(removedIds), updated: updated) + } } diff --git a/Sources/StreamFeeds/Models/PollData.swift b/Sources/StreamFeeds/Models/PollData.swift index b94da93..8f7a708 100644 --- a/Sources/StreamFeeds/Models/PollData.swift +++ b/Sources/StreamFeeds/Models/PollData.swift @@ -74,47 +74,6 @@ extension PollData { mutating func updateOption(_ option: PollOptionData) { options.replace(byId: option) } - - // MARK: - Votes - - mutating func castVote(_ vote: PollVoteData, currentUserId: String) { - if enforceUniqueVote, vote.user?.id == currentUserId { - for ownVote in ownVotes { - removeVote(ownVote, currentUserId: currentUserId) - } - ownVotes = [vote] - } else { - ownVotes.insert(byId: vote) - } - - var optionVotes = latestVotesByOption[vote.optionId] ?? [] - let optionVoteCount = optionVotes.count - optionVotes.insert(byId: vote) - latestVotesByOption[vote.optionId] = optionVotes - - if optionVotes.count != optionVoteCount { - let optionVoteCounts = voteCountsByOption[vote.optionId] ?? 0 - voteCountsByOption[vote.optionId] = optionVoteCounts + 1 - } - - voteCount = voteCountsByOption.reduce(0) { $0 + $1.value } - } - - mutating func removeVote(_ vote: PollVoteData, currentUserId: String) { - if vote.user?.id == currentUserId { - ownVotes.remove(byId: vote.id) - } - - var optionVotes = latestVotesByOption[vote.optionId] ?? [] - let optionVoteCount = optionVotes.count - optionVotes.remove(byId: vote.id) - latestVotesByOption[vote.optionId] = optionVotes - - if optionVotes.count != optionVoteCount { - let optionVoteCounts = voteCountsByOption[vote.optionId] ?? 0 - voteCountsByOption[vote.optionId] = max(0, optionVoteCounts - 1) - } - } } // MARK: - Model Conversions diff --git a/Sources/StreamFeeds/Models/ReactionGroupData.swift b/Sources/StreamFeeds/Models/ReactionGroupData.swift index 4b3801e..efcc955 100644 --- a/Sources/StreamFeeds/Models/ReactionGroupData.swift +++ b/Sources/StreamFeeds/Models/ReactionGroupData.swift @@ -9,21 +9,7 @@ public struct ReactionGroupData: Equatable, Sendable { public private(set) var count: Int public let firstReactionAt: Date public private(set) var lastReactionAt: Date -} - -extension ReactionGroupData { - var isEmpty: Bool { count <= 0 } - - mutating func decrement(with date: Date) { - guard date >= firstReactionAt || date <= lastReactionAt else { return } - count = max(0, count - 1) - } - - mutating func increment(with date: Date) { - guard date > firstReactionAt else { return } - count += 1 - lastReactionAt = date - } + public let sumScores: Int? } // MARK: - Model Conversions @@ -33,7 +19,8 @@ extension ReactionGroupResponse { ReactionGroupData( count: count, firstReactionAt: firstReactionAt, - lastReactionAt: lastReactionAt + lastReactionAt: lastReactionAt, + sumScores: sumScores ) } } diff --git a/Sources/StreamFeeds/Models/ThreadedCommentData.swift b/Sources/StreamFeeds/Models/ThreadedCommentData.swift index b21485e..cdc8a60 100644 --- a/Sources/StreamFeeds/Models/ThreadedCommentData.swift +++ b/Sources/StreamFeeds/Models/ThreadedCommentData.swift @@ -249,22 +249,6 @@ extension ThreadedCommentData { user = incomingData } - mutating func addReaction(_ reaction: FeedsReactionData, currentUserId: String) { - FeedsReactionData.updateByAdding(reaction: reaction, to: &latestReactions, reactionGroups: &reactionGroups) - reactionCount = reactionGroups.values.reduce(0) { $0 + $1.count } - if reaction.user.id == currentUserId { - ownReactions.append(reaction) - } - } - - mutating func removeReaction(_ reaction: FeedsReactionData, currentUserId: String) { - FeedsReactionData.updateByRemoving(reaction: reaction, from: &latestReactions, reactionGroups: &reactionGroups) - reactionCount = reactionGroups.values.reduce(0) { $0 + $1.count } - if reaction.user.id == currentUserId { - ownReactions.remove(byId: reaction.id) - } - } - // MARK: - Replies mutating func addReply(_ comment: ThreadedCommentData, sort areInIncreasingOrder: (ThreadedCommentData, ThreadedCommentData) -> Bool) { @@ -292,39 +276,6 @@ extension ThreadedCommentData { replies.sortedReplace(comment, nesting: nil, sorting: areInIncreasingOrder) self.replies = replies } - - // MARK: - Updating - - mutating func setCommentData(_ comment: CommentData) { - self = ThreadedCommentData( - attachments: comment.attachments, - confidenceScore: comment.confidenceScore, - controversyScore: comment.controversyScore, - createdAt: comment.createdAt, - custom: comment.custom, - deletedAt: comment.deletedAt, - downvoteCount: comment.downvoteCount, - id: comment.id, - latestReactions: comment.latestReactions, - mentionedUsers: comment.mentionedUsers, - meta: meta, - moderation: comment.moderation, - objectId: comment.objectId, - objectType: comment.objectType, - ownReactions: comment.ownReactions, - parentId: comment.parentId, - reactionCount: comment.reactionCount, - reactionGroups: comment.reactionGroups, - replies: replies, - replyCount: comment.replyCount, - score: comment.score, - status: comment.status, - text: comment.text, - updatedAt: comment.updatedAt, - upvoteCount: comment.upvoteCount, - user: comment.user - ) - } } // MARK: - Sorting diff --git a/Sources/StreamFeeds/Models/UserData.swift b/Sources/StreamFeeds/Models/UserData.swift index 643b106..9c33221 100644 --- a/Sources/StreamFeeds/Models/UserData.swift +++ b/Sources/StreamFeeds/Models/UserData.swift @@ -6,6 +6,7 @@ import Foundation import StreamCore public struct UserData: Identifiable, Equatable, Sendable, Hashable { + public let avgResponseTime: Int? public let banned: Bool public let blockedUserIds: [String] public let createdAt: Date @@ -35,6 +36,7 @@ public struct UserData: Identifiable, Equatable, Sendable, Hashable { extension UserResponse { func toModel() -> UserData { UserData( + avgResponseTime: avgResponseTime, banned: banned, blockedUserIds: blockedUserIds, createdAt: createdAt, @@ -59,6 +61,7 @@ extension UserResponse { extension UserResponseCommonFields { func toModel() -> UserData { UserData( + avgResponseTime: nil, // UserResponseCommonFields doesn't have avgResponseTime banned: banned, blockedUserIds: blockedUserIds, createdAt: createdAt, @@ -83,6 +86,7 @@ extension UserResponseCommonFields { extension UserResponsePrivacyFields { func toModel() -> UserData { UserData( + avgResponseTime: nil, // UserResponsePrivacyFields doesn't have avgResponseTime banned: banned, blockedUserIds: blockedUserIds, createdAt: createdAt, diff --git a/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift b/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift index 1cf9b38..b04b997 100644 --- a/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift +++ b/Sources/StreamFeeds/Repositories/ActivitiesRepository.swift @@ -123,7 +123,7 @@ final class ActivitiesRepository: Sendable { // MARK: - Reactions func addReaction(activityId: String, request: AddReactionRequest) async throws -> (reaction: FeedsReactionData, activity: ActivityData) { - let response = try await apiClient.addReaction(activityId: activityId, addReactionRequest: request) + let response = try await apiClient.addActivityReaction(activityId: activityId, addReactionRequest: request) return (response.reaction.toModel(), response.activity.toModel()) } diff --git a/Sources/StreamFeeds/Repositories/FeedsRepository.swift b/Sources/StreamFeeds/Repositories/FeedsRepository.swift index 0bfcc22..0086b5d 100644 --- a/Sources/StreamFeeds/Repositories/FeedsRepository.swift +++ b/Sources/StreamFeeds/Repositories/FeedsRepository.swift @@ -26,27 +26,44 @@ final class FeedsRepository: Sendable { getOrCreateFeedRequest: request ) let rawFollowers = response.followers.map { $0.toModel() } + let rawFollowing = response.following.map { $0.toModel() } + let activities = response.activities.map { $0.toModel() } + let pinnedActivities = response.pinnedActivities.map { $0.toModel() } + let ownCapabilities = response.feed.ownCapabilities.map(Set.init) ?? Set() + let allFeedDatas: [FeedData] = + activities.compactMap(\.currentFeed) + + pinnedActivities.compactMap(\.activity.currentFeed) + + rawFollowers.compactMap(\.sourceFeed) + + rawFollowers.compactMap(\.targetFeed) + + rawFollowing.compactMap(\.sourceFeed) + + rawFollowing.compactMap(\.targetFeed) + let allOwnCapabilities = allFeedDatas + .reduce(into: [feed: ownCapabilities], { all, feedData in + guard let capabilities = feedData.ownCapabilities, !capabilities.isEmpty else { return } + all[feedData.feed] = capabilities + }) return GetOrCreateInfo( activities: PaginationResult( - models: response.activities.map { $0.toModel() }.sorted(using: Sort.defaultSorting), + models: activities.sorted(using: Sort.defaultSorting), pagination: PaginationData(next: response.next, previous: response.prev) ), activitiesQueryConfig: QueryConfiguration( filter: query.activityFilter, sort: Sort.defaultSorting ), + aggregatedActivities: response.aggregatedActivities.map { $0.toModel() }, + allOwnCapabilities: allOwnCapabilities, feed: response.feed.toModel(), - followers: rawFollowers.filter { $0.isFollower(of: feed) }, - following: response.following.map { $0.toModel() }.filter { $0.isFollowing(feed) }, followRequests: rawFollowers.filter(\.isFollowRequest), + followers: rawFollowers.filter { $0.isFollower(of: feed) }, + following: rawFollowing.filter { $0.isFollowing(feed) }, members: PaginationResult( models: response.members.map { $0.toModel() }, pagination: response.memberPagination?.toModel() ?? .empty ), - ownCapabilities: response.feed.ownCapabilities ?? [], - pinnedActivities: response.pinnedActivities.map { $0.toModel() }, - aggregatedActivities: response.aggregatedActivities.map { $0.toModel() }, - notificationStatus: response.notificationStatus?.toModel() + notificationStatus: response.notificationStatus?.toModel(), + ownCapabilities: ownCapabilities, + pinnedActivities: pinnedActivities ) } @@ -145,14 +162,15 @@ extension FeedsRepository { struct GetOrCreateInfo { let activities: PaginationResult let activitiesQueryConfig: QueryConfiguration + let aggregatedActivities: [AggregatedActivityData] + let allOwnCapabilities: [FeedId: Set] let feed: FeedData + let followRequests: [FollowData] let followers: [FollowData] let following: [FollowData] - let followRequests: [FollowData] let members: PaginationResult - let ownCapabilities: [FeedOwnCapability] - let pinnedActivities: [ActivityPinData] - let aggregatedActivities: [AggregatedActivityData] let notificationStatus: NotificationStatusData? + let ownCapabilities: Set + let pinnedActivities: [ActivityPinData] } } diff --git a/Sources/StreamFeeds/Repositories/OwnCapabilitiesRepository.swift b/Sources/StreamFeeds/Repositories/OwnCapabilitiesRepository.swift new file mode 100644 index 0000000..e3d1f3d --- /dev/null +++ b/Sources/StreamFeeds/Repositories/OwnCapabilitiesRepository.swift @@ -0,0 +1,67 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// This is a repository which holds a shared state and manages +/// a map of feed id to capabilities. +final class OwnCapabilitiesRepository: Sendable { + private let apiClient: DefaultAPI + private let storage = AllocatedUnfairLock([FeedId: Set]()) + + init(apiClient: DefaultAPI) { + self.apiClient = apiClient + } + + // MARK: - Get Capabilities + + /// Returns locally cached capabilities if available. + func capabilities(for feed: FeedId) -> Set? { + self.capabilities(for: Set(arrayLiteral: feed))?[feed] + } + + /// Returns locally cached capabilities if available. + func capabilities(for feeds: Set) -> [FeedId: Set]? { + let cached = storage.withLock { storage in + feeds.reduce(into: [FeedId: Set](), { all, feedId in + guard let cached = storage[feedId] else { return } + all[feedId] = cached + }) + } + if cached.count == feeds.count { + return cached + } + return nil + } + + func getCapabilities(for feeds: Set) async throws -> [FeedId: Set] { + let response = try await apiClient.ownCapabilitiesBatch(ownCapabilitiesBatchRequest: OwnCapabilitiesBatchRequest(feeds: feeds.map(\.rawValue))) + return Dictionary(uniqueKeysWithValues: response.capabilities.map { (FeedId(rawValue: $0), Set($1)) }) + } + + // MARK: - Saving Capabilities + + func saveCapabilities(_ newCapabilities: [FeedId: Set]) -> [FeedId: Set]? { + guard !newCapabilities.isEmpty else { return nil } + return storage.withLock { storage in + // Find only the ones which had a state before + let changed = newCapabilities.filter { storage[$0] != nil && storage[$0] != $1 } + storage.merge(newCapabilities, uniquingKeysWith: { _, new in new }) + return changed + } + } + + func saveCapabilities(in feedDatas: [FeedData]) -> [FeedId: Set]? { + guard !feedDatas.isEmpty else { return nil } + let all = feedDatas.reduce(into: [FeedId: Set](), { all, feedData in + guard let capabilities = feedData.ownCapabilities, !capabilities.isEmpty else { return } + all[feedData.feed] = capabilities + }) + return saveCapabilities(all) + } + + func saveCapabilities(in feedData: FeedData?) -> [FeedId: Set]? { + saveCapabilities(in: [feedData].compactMap { $0 }) + } +} diff --git a/Sources/StreamFeeds/StateLayer/Activity.swift b/Sources/StreamFeeds/StateLayer/Activity.swift index 22cb745..473ba5c 100644 --- a/Sources/StreamFeeds/StateLayer/Activity.swift +++ b/Sources/StreamFeeds/StateLayer/Activity.swift @@ -14,6 +14,7 @@ public final class Activity: Sendable { private let commentList: ActivityCommentList private let activitiesRepository: ActivitiesRepository private let commentsRepository: CommentsRepository + private let ownCapabilitiesRepository: OwnCapabilitiesRepository private let pollsRepository: PollsRepository private let eventPublisher: StateLayerEventPublisher @MainActor private let stateBuilder: StateBuilder @@ -39,6 +40,7 @@ public final class Activity: Sendable { commentsRepository = client.commentsRepository eventPublisher = client.stateLayerEventPublisher self.feed = feed + ownCapabilitiesRepository = client.ownCapabilitiesRepository pollsRepository = client.pollsRepository let currentUserId = client.user.id stateBuilder = StateBuilder { [currentUserId, eventPublisher] in @@ -69,6 +71,9 @@ public final class Activity: Sendable { async let comments = queryComments() let (activityData, _) = try await (activity, comments) await state.setActivity(activityData) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: activityData.currentFeed) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return activityData } diff --git a/Sources/StreamFeeds/StateLayer/ActivityState.swift b/Sources/StreamFeeds/StateLayer/ActivityState.swift index f05ab2f..f140251 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 @@ -54,6 +54,12 @@ extension ActivityState { case .activityUpdated(let activityData, let eventFeedId): guard activityData.id == activityId, eventFeedId == feed else { return } await self?.setActivity(activityData) + case .activityBatchUpdate(let updates): + if let updated = updates.updated.first(where: { $0.id == activityId }) { + await self?.setActivity(updated) + } else if updates.removedIds.contains(activityId) { + await self?.setActivity(nil) + } case .activityReactionAdded(let reactionData, let activityData, let eventFeedId): guard activityData.id == activityId, eventFeedId == feed else { return } await self?.access { state in @@ -108,6 +114,10 @@ extension ActivityState { state.activity?.addComment(commentData) } } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.activity?.mergeFeedOwnCapabilities(from: capabilitiesMap) + } case .pollDeleted(let pollId, let eventFeedId): guard eventFeedId == feed else { return } await self?.access { state in @@ -148,8 +158,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 0000000..8e07503 --- /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/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware.swift b/Sources/StreamFeeds/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware.swift new file mode 100644 index 0000000..15d7408 --- /dev/null +++ b/Sources/StreamFeeds/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware.swift @@ -0,0 +1,155 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore + +/// Extracts own capabilities from events and saves it to the shared +/// store in the capabilities repository. +final class OwnCapabilitiesStateLayerEventMiddleware: StateLayerEventMiddleware { + private let sendEvent: @Sendable (StateLayerEvent) async -> Void + let ownCapabilitiesRepository: OwnCapabilitiesRepository + + init(ownCapabilitiesRepository: OwnCapabilitiesRepository, sendEvent: @escaping @Sendable (StateLayerEvent) async -> Void) { + self.ownCapabilitiesRepository = ownCapabilitiesRepository + self.sendEvent = sendEvent + } + + // MARK: - Processing Events + + /// Adds own capabilities to web-socket added events and extracts own capabilities from local events. + /// + /// - Note: Added events are only enriched because state-layer merged WS event data by keeping own fields. Therefore, + /// updated events do not need to have capabilities correctly set. Secondly, state-layer uses feedOwnCapabilitiesUpdated + /// to automatically apply updated capablities. + func willPublish(_ event: StateLayerEvent, from source: StateLayerEventPublisher.EventSource) async -> StateLayerEvent { + switch source { + case .webSocket: + switch event { + case .activityAdded(let activityData, let feedId): + guard let capabilitiesMap = await cachedCapabilities(for: activityData) else { break } + return .activityAdded(activityData.withFeedOwnCapabilities(from: capabilitiesMap), feedId) + case .bookmarkAdded(let bookmarkData): + guard let capabilitiesMap = await cachedCapabilities(for: bookmarkData.activity) else { break } + return .bookmarkAdded(bookmarkData.withFeedOwnCapabilities(from: capabilitiesMap)) + case .feedAdded(let feedData, let feedId): + guard let capabilitiesMap = await cachedCapabilities(for: feedData.feed) else { break } + return .feedAdded(feedData.withFeedOwnCapabilities(from: capabilitiesMap), feedId) + case .feedFollowAdded(let followData, let feedId): + guard let capabilitiesMap = await cachedCapabilities(for: Set([followData.sourceFeed.feed, followData.targetFeed.feed])) else { break } + return .feedFollowAdded(followData.withFeedOwnCapabilities(from: capabilitiesMap), feedId) + default: + return event + } + case .local: + if let updated = ownCapabilitiesRepository.saveCapabilities(event.ownCapabilities) { + await sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } + } + return event + } + + private func cachedCapabilities(for activity: ActivityData) async -> [FeedId: Set]? { + guard let feedData = activity.currentFeed else { return nil } + return await cachedCapabilities(for: feedData.feed) + } + + private func cachedCapabilities(for feed: FeedId) async -> [FeedId: Set]? { + guard let capabilities = ownCapabilitiesRepository.capabilities(for: feed) else { + scheduleFetchingMissingCapabilities(for: [feed]) + return nil + } + return [feed: capabilities] + } + + private func cachedCapabilities(for feeds: Set) async -> [FeedId: Set]? { + guard let capabilities = ownCapabilitiesRepository.capabilities(for: feeds) else { + scheduleFetchingMissingCapabilities(for: feeds) + return nil + } + return capabilities + } + + // MARK: - Fetch Missing Capabilities + + /// Most of the case capabilities are cached since we read them from HTTP responses. One example where this + /// can not be the case is where timeline feed is getting new activities from other feeds. For these, + /// we still fill in capabilities automatically. + /// + /// - Note: This is not async function because we don't want suspend event handling while fetching additional capabilities + private func scheduleFetchingMissingCapabilities(for feedIds: Set) { + Task { + do { + let fetchedCapabilities = try await ownCapabilitiesRepository.getCapabilities(for: feedIds) + _ = ownCapabilitiesRepository.saveCapabilities(fetchedCapabilities) + // Here we explicitly send the update for making state-layer to fill in capabilities (default case is that newly inserted capabilities do not trigger local events) + await sendEvent(.feedOwnCapabilitiesUpdated(fetchedCapabilities)) + } catch { + log.error("Failed to fetch missing feed capabilities for number of feeds: \(feedIds.count)", error: error) + } + } + } +} + +private extension StateLayerEvent { + var ownCapabilities: [FeedId: Set] { + guard let feedDatas, !feedDatas.isEmpty else { return [:] } + return feedDatas.reduce(into: [FeedId: Set](), { all, feedData in + guard let capabilities = feedData.ownCapabilities, !capabilities.isEmpty else { return } + all[feedData.feed] = capabilities + }) + } + + var feedDatas: [FeedData]? { + switch self { + case .activityAdded(let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityUpdated(let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityBatchUpdate(let updates): + return (updates.added + updates.updated).compactMap(\.currentFeed) + case .activityReactionAdded(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityReactionDeleted(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityReactionUpdated(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .activityPinned(let activityPinData, _): + return activityPinData.activity.currentFeed.map { [$0] } + case .activityUnpinned(let activityPinData, _): + return activityPinData.activity.currentFeed.map { [$0] } + case .bookmarkAdded(let bookmarkData): + return bookmarkData.activity.currentFeed.map { [$0] } + case .bookmarkDeleted(let bookmarkData): + return bookmarkData.activity.currentFeed.map { [$0] } + case .bookmarkUpdated(let bookmarkData): + return bookmarkData.activity.currentFeed.map { [$0] } + case .commentAdded(_, let activityData, _): + return activityData.currentFeed.map { [$0] } + case .feedAdded(let feedData, _): + return [feedData] + case .feedUpdated(let feedData, _): + return [feedData] + case .feedFollowAdded(let followData, _): + return [followData.sourceFeed, followData.targetFeed] + case .feedFollowDeleted(let followData, _): + return [followData.sourceFeed, followData.targetFeed] + case .feedFollowUpdated(let followData, _): + return [followData.sourceFeed, followData.targetFeed] + case .activityMarked, .activityDeleted, + .bookmarkFolderDeleted, .bookmarkFolderUpdated, + .commentDeleted, .commentUpdated, .commentsAddedBatch, + .commentReactionAdded, .commentReactionDeleted, .commentReactionUpdated, + .pollDeleted, .pollUpdated, .pollVoteCasted, .pollVoteChanged, .pollVoteDeleted, + .feedDeleted, + .feedGroupDeleted, .feedGroupUpdated, + .feedMemberAdded, .feedMemberDeleted, .feedMemberUpdated, .feedMemberBatchUpdate, + .notificationFeedUpdated, + .userUpdated, + .feedOwnCapabilitiesUpdated: + return nil + } + } +} diff --git a/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEvent.swift similarity index 91% rename from Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift rename to Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEvent.swift index b7d287f..b4328e8 100644 --- a/Sources/StreamFeeds/StateLayer/Common/StateLayerEvent.swift +++ b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEvent.swift @@ -10,6 +10,7 @@ enum StateLayerEvent: Sendable { case activityAdded(ActivityData, FeedId) case activityDeleted(String, FeedId) case activityUpdated(ActivityData, FeedId) + case activityBatchUpdate(ModelUpdates) case activityReactionAdded(FeedsReactionData, ActivityData, FeedId) case activityReactionDeleted(FeedsReactionData, ActivityData, FeedId) @@ -60,6 +61,21 @@ enum StateLayerEvent: Sendable { case notificationFeedUpdated(FeedId) case userUpdated(UserData) + + // + // Local events not related to any particular web-socket event + // + + /// Local capabilities tracking detected that capabilities changed for the feed. + /// + /// Web-socket events do not have `own_` fields set and therefore state layer uses + /// related WS events to keep the data up to date (e.g. activity reactions + /// are managed by WS events: added, updated, removed). + /// Capabilities are special because every feed has capabilities for the current + /// user. Therefore need to make sure (`ActivityData.currentFeed.ownCapabilities`) is set + /// when activities are added. For this particular case we have bookkeeping and we make + /// sure state-layer updates `ownCapabilities` for already fetched models as well. + case feedOwnCapabilitiesUpdated([FeedId: Set]) } extension StateLayerEvent { diff --git a/Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEventPublisher.swift similarity index 51% rename from Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift rename to Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEventPublisher.swift index 5ed7ed1..e12ca29 100644 --- a/Sources/StreamFeeds/StateLayer/Common/StateLayerEventPublisher.swift +++ b/Sources/StreamFeeds/StateLayer/EventHandling/StateLayerEventPublisher.swift @@ -13,15 +13,25 @@ import StreamCore /// filtering before consuming the event on the main thread. final class StateLayerEventPublisher: WSEventsSubscriber, Sendable { private let subscriptions = AllocatedUnfairLock<[UUID: @Sendable (StateLayerEvent) async -> Void]>([:]) + private let middlewares = AllocatedUnfairLock([StateLayerEventMiddleware]()) + + func addMiddlewares(_ additionalMiddlewares: [StateLayerEventMiddleware]) { + middlewares.withLock { $0.append(contentsOf: additionalMiddlewares) } + } /// Send individual events to all the subscribers. /// /// Triggered by incoming web-socket events and manually after API calls. /// /// - Parameter event: The state layer change event. - func sendEvent(_ event: StateLayerEvent) async { + /// - Parameter source: The source where the event is coming from: web-socket or internal. + func sendEvent(_ event: StateLayerEvent, source: EventSource = .local) async { + var event = event + for middleware in middlewares.value { + event = await middleware.willPublish(event, from: source) + } let handlers = Array(subscriptions.value.values) - await withTaskGroup(of: Void.self) { group in + await withTaskGroup(of: Void.self) { [event] group in for handler in handlers { group.addTask { await handler(event) @@ -46,16 +56,43 @@ final class StateLayerEventPublisher: WSEventsSubscriber, Sendable { func onEvent(_ event: any Event) async { guard let stateLayerEvent = StateLayerEvent(event: event) else { return } - await sendEvent(stateLayerEvent) + await sendEvent(stateLayerEvent, source: .webSocket) + + switch stateLayerEvent { + case .activityAdded(let activityData, let eventFeedId): + // Parent does not get WS update, but reposting updates its data (e.g. share count) + if let parent = activityData.parent { + await sendEvent(.activityUpdated(parent, eventFeedId)) + } + default: + break + } + } +} + +extension StateLayerEventPublisher { + enum EventSource { + /// Events which are coming in from the web-socket. + case webSocket + /// Events which are internally published with HTTP response data. + case local } } +protocol StateLayerEventMiddleware: Sendable { + func willPublish(_ event: StateLayerEvent, from source: StateLayerEventPublisher.EventSource) async -> StateLayerEvent +} + extension StateLayerEventPublisher { final class Subscription: Sendable { - private let cancel: @Sendable () -> Void + private let cancellationHandler: @Sendable () -> Void init(cancel: @escaping @Sendable () -> Void) { - self.cancel = cancel + self.cancellationHandler = cancel + } + + func cancel() { + cancellationHandler() } deinit { diff --git a/Sources/StreamFeeds/StateLayer/Feed.swift b/Sources/StreamFeeds/StateLayer/Feed.swift index 9498ad7..f74b8c5 100644 --- a/Sources/StreamFeeds/StateLayer/Feed.swift +++ b/Sources/StreamFeeds/StateLayer/Feed.swift @@ -33,8 +33,9 @@ public final class Feed: Sendable { private let bookmarksRepository: BookmarksRepository private let commentsRepository: CommentsRepository private let feedsRepository: FeedsRepository - private let pollsRepository: PollsRepository private let memberList: MemberList + private let ownCapabilitiesRepository: OwnCapabilitiesRepository + private let pollsRepository: PollsRepository init(query: FeedQuery, client: FeedsClient) { activitiesRepository = client.activitiesRepository @@ -45,6 +46,7 @@ public final class Feed: Sendable { feedsRepository = client.feedsRepository eventPublisher = client.stateLayerEventPublisher memberList = client.memberList(for: .init(feed: query.feed)) + ownCapabilitiesRepository = client.ownCapabilitiesRepository pollsRepository = client.pollsRepository let currentUserId = client.user.id stateBuilder = StateBuilder { [eventPublisher, memberList] in @@ -89,6 +91,9 @@ public final class Feed: Sendable { public func getOrCreate() async throws -> FeedData { let result = try await feedsRepository.getOrCreateFeed(with: feedQuery) await state.didQueryFeed(with: result) + if let updated = ownCapabilitiesRepository.saveCapabilities(result.allOwnCapabilities) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.feed } diff --git a/Sources/StreamFeeds/StateLayer/FeedQuery.swift b/Sources/StreamFeeds/StateLayer/FeedQuery.swift index c8018bf..2924d72 100644 --- a/Sources/StreamFeeds/StateLayer/FeedQuery.swift +++ b/Sources/StreamFeeds/StateLayer/FeedQuery.swift @@ -161,7 +161,7 @@ extension FeedQuery { activitySelectorOptions: activitySelectorOptions, data: data, externalRanking: externalRanking, - filter: activityFilter?.toRawJSON(), + filter: activityFilter?.toRawJSONDictionary(), followersPagination: followerLimit.flatMap { PagerRequest(limit: $0) }, followingPagination: followingLimit.flatMap { PagerRequest(limit: $0) }, interestWeights: interestWeights, diff --git a/Sources/StreamFeeds/StateLayer/FeedState.swift b/Sources/StreamFeeds/StateLayer/FeedState.swift index 6c77456..b6c8c5e 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 @@ -60,7 +60,7 @@ import StreamCore @Published public private(set) var members = [FeedMemberData]() /// The capabilities that the current user has for this feed. - @Published public internal(set) var ownCapabilities = [FeedOwnCapability]() + @Published public internal(set) var ownCapabilities = Set() /// The list of pinned activities and its pinning state. @Published public private(set) var pinnedActivities = [ActivityPinData]() @@ -91,13 +91,15 @@ import StreamCore extension FeedState { private func subscribe(to publisher: StateLayerEventPublisher) { + let matchesActivityQuery: @Sendable (ActivityData) -> Bool = { [feedQuery] activity in + guard let filter = feedQuery.activityFilter else { return true } + return filter.matches(activity) + } eventSubscription = publisher.subscribe { [weak self, currentUserId, feed, feedQuery] event in switch event { case .activityAdded(let activityData, let eventFeedId): guard feed == eventFeedId else { return } - if let filter = feedQuery.activityFilter, !filter.matches(activityData) { - return - } + guard matchesActivityQuery(activityData) else { return } await self?.access { $0.activities.sortedInsert(activityData, sorting: $0.activitiesSorting) } case .activityDeleted(let activityId, let eventFeedId): guard feed == eventFeedId else { return } @@ -108,6 +110,24 @@ extension FeedState { case .activityUpdated(let activityData, let eventFeedId): guard feed == eventFeedId else { return } await self?.updateActivity(activityData) + case .activityBatchUpdate(let updates): + let added = updates.added.filter { $0.feeds.contains(feed.rawValue) }.filter(matchesActivityQuery) + let updated = updates.updated.filter { $0.feeds.contains(feed.rawValue) }.filter(matchesActivityQuery) + let removedIds = updates.removedIds + guard !added.isEmpty || !updated.isEmpty || !removedIds.isEmpty else { return } + await self?.access { state in + let sorting = state.activitiesSorting + if !added.isEmpty { + state.activities = state.activities.sortedMerge(added.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !updated.isEmpty { + state.activities = state.activities.sortedMerge(updated.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !removedIds.isEmpty { + state.activities.removeAll(where: { removedIds.contains($0.id) }) + state.pinnedActivities.removeAll(where: { removedIds.contains($0.activity.id) }) + } + } case .activityReactionAdded(let reactionData, let activityData, let eventFeedId): guard feed == eventFeedId else { return } await self?.access { state in @@ -297,7 +317,7 @@ extension FeedState { } case .feedUpdated(let feedData, let eventFeedId): guard feed == eventFeedId else { return } - await self?.access { $0.feedData = feedData } + await self?.access { $0.feedData?.merge(with: feedData) } case .feedFollowAdded(let followData, let eventFeedId): guard feed == eventFeedId else { return } await self?.addFollow(followData) @@ -310,6 +330,33 @@ extension FeedState { case .feedMemberAdded, .feedMemberDeleted, .feedMemberUpdated: // Handled by member list break + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + if let capabilities = capabilitiesMap[feed] { + state.feedData?.setOwnCapabilities(capabilities) + state.ownCapabilities = capabilities + } + state.activities.updateAll( + where: { capabilitiesMap.contains($0.currentFeed?.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.pinnedActivities.updateAll( + where: { capabilitiesMap.contains($0.activity.currentFeed?.feed) }, + changes: { $0.activity.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.followers.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.following.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + state.followRequests.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } case .pollDeleted(let pollId, let eventFeedId): guard eventFeedId == feed else { return } await self?.access { state in @@ -376,10 +423,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/ActivitiesQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivitiesQuery.swift index 117c130..0ef6cc6 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivitiesQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivitiesQuery.swift @@ -68,57 +68,75 @@ public struct ActivitiesFilterField: FilterFieldRepresentable, Sendable { self.rawValue = rawValue matcher = AnyFilterMatcher(localValue: localValue) } - - init(_ codingKey: ActivityResponse.CodingKeys, localValue: @escaping @Sendable (Model) -> Value?) where Value: FilterValue { - self.init(codingKey.rawValue, localValue: localValue) - } } extension ActivitiesFilterField { + /// Filter by the type of activity (e.g., "post", "comment", "reaction"). + /// + /// **Supported operators:** `.equal`, `.in` + public static let activityType = Self("activity_type", localValue: \.type) + /// Filter by the creation timestamp of the activity. /// /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` - public static let createdAt = Self(.createdAt, localValue: \.createdAt) + public static let createdAt = Self("created_at", localValue: \.createdAt) - /// Filter by the expiry date. + /// Filter by the expiry date of the activity. /// - /// **Supported operators:** `.exists` - public static let expiresAt = Self(.expiresAt, localValue: \.expiresAt) + /// **Supported operators:** `.equal`, `.notEqual`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual`, `.exists` + public static let expiresAt = Self("expires_at", localValue: \.expiresAt) - /// Filter by the unique identifier of the activity. + /// Filter by the feed ID(s) that the activity belongs to. /// /// **Supported operators:** `.equal`, `.in` - public static let id = Self(.id, localValue: \.id) + public static let feed = Self("feed", localValue: \.feeds) /// Filter by the filter tags associated with the activity. /// /// **Supported operators:** `.equal`, `.in`, `.contains` - public static let filterTags = Self(.filterTags, localValue: \.filterTags) + public static let filterTags = Self("filter_tags", localValue: \.filterTags) + + /// Filter by the unique identifier of the activity. + /// + /// **Supported operators:** `.equal`, `.in` + public static let id = Self("id", localValue: \.id) + + /// Filter by the interest tags associated with the activity. + /// + /// **Supported operators:** `.equal`, `.in`, `.contains` + public static let interestTags = Self("interest_tags", localValue: \.interestTags) + + /// Filter by proximity to a location. + /// Requires an object with `lat` (latitude), `lng` (longitude), and `distance` (in kilometers). + /// + /// **Supported operators:** `.equal` + public static let near = Self("near", localValue: \.location) /// Filter by the popularity score of the activity. /// /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` - public static let popularity = Self(.popularity, localValue: \.popularity) + public static let popularity = Self("popularity", localValue: \.popularity) - /// Filter by the search data content of the activity. + /// Filter by the search data content of the activity (JSON object). /// - /// **Supported operators:** `.equal`, `.q`, `.autocomplete` - public static let searchData = Self(.searchData, localValue: \.searchData) + /// **Supported operators:** `.contains`, `.in`, `.pathExists` + public static let searchData = Self("search_data", localValue: \.searchData) /// Filter by the text content of the activity. /// - /// **Supported operators:** `.equal`, `.q`, `.autocomplete` - public static let text = Self(.text, localValue: \.text) - - /// Filter by the type of activity (e.g., "post", "comment", "reaction"). - /// - /// **Supported operators:** `.equal`, `.in` - public static let type = Self(.type, localValue: \.type) + /// **Supported operators:** `.equal`, `.q` (full-text search), `.autocomplete` + public static let text = Self("text", localValue: \.text) /// Filter by the user ID who created the activity. /// /// **Supported operators:** `.equal`, `.in` public static let userId = Self("user_id", localValue: \.user.id) + + /// Filter by activities within a bounding box. + /// Requires an object with `ne_lat`, `ne_lng` (northeast corner), `sw_lat`, `sw_lng` (southwest corner). + /// + /// **Supported operators:** `.equal` + public static let withinBounds = Self("within_bounds", localValue: \.location) } /// A filter that can be applied to activities queries. @@ -206,7 +224,9 @@ public struct ActivitiesSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension ActivitiesSortField { /// Sort by the creation timestamp of the activity. /// This field allows sorting activities by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -230,7 +250,7 @@ extension ActivitiesQuery { /// - Returns: A `QueryActivitiesRequest` object that can be sent to the API. func toRequest() -> QueryActivitiesRequest { QueryActivitiesRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityCommentListState.swift index 12221ea..29181aa 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,16 @@ 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 .activityBatchUpdate(let updates): + guard updates.removedIds.contains(query.objectId) 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 +235,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/ActivityList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityList.swift index b64ffca..9274e4a 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityList.swift @@ -13,11 +13,14 @@ import StreamCore public final class ActivityList: Sendable { @MainActor private let stateBuilder: StateBuilder private let activitiesRepository: ActivitiesRepository + private let eventPublisher: StateLayerEventPublisher + private let ownCapabilitiesRepository: OwnCapabilitiesRepository init(query: ActivitiesQuery, client: FeedsClient) { activitiesRepository = client.activitiesRepository + eventPublisher = client.stateLayerEventPublisher + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query - let eventPublisher = client.stateLayerEventPublisher let currentUserId = client.user.id stateBuilder = StateBuilder { [eventPublisher] in ActivityListState( @@ -87,6 +90,9 @@ public final class ActivityList: Sendable { with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models.compactMap(\.currentFeed)) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.models } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityListState.swift index feaec92..0a292e6 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,14 +88,35 @@ 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 state.activities.removeAll { $0.id == activityId } } + case .activityBatchUpdate(let updates): + let added = updates.added.filter(matchesQuery) + let updated = updates.updated.filter(matchesQuery) + let removedIds = updates.removedIds + guard !added.isEmpty || !updated.isEmpty || !removedIds.isEmpty else { return } + await self?.access { state in + let sorting = state.activitiesSorting + if !added.isEmpty { + state.activities = state.activities.sortedMerge(added.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !updated.isEmpty { + state.activities = state.activities.sortedMerge(updated.sorted(by: sorting.areInIncreasingOrder()), sorting: sorting) + } + if !removedIds.isEmpty { + state.activities.removeAll(where: { removedIds.contains($0.id) }) + } + } case .activityReactionAdded(let reactionData, let activityData, _): guard matchesQuery(activityData) else { return } await self?.access { state in @@ -180,6 +201,13 @@ extension ActivityListState { } ) } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.activities.updateAll( + where: { capabilitiesMap.contains($0.currentFeed?.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } case .pollUpdated(let pollData, _): await self?.access { state in state.activities.updateFirstElement( @@ -221,10 +249,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 daa85b1..c754047 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/ActivityReactionsQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityReactionsQuery.swift index f31068e..80b8fdf 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityReactionsQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ActivityReactionsQuery.swift @@ -161,7 +161,9 @@ public struct ActivityReactionsSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension ActivityReactionsSortField { /// Sort by the creation timestamp of the reaction. /// This field allows sorting reactions by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -178,7 +180,7 @@ extension Sort where Field == ActivityReactionsSortField { extension ActivityReactionsQuery { func toRequest() -> QueryActivityReactionsRequest { QueryActivityReactionsRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderList.swift index 29a2f41..3c2f3fc 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderList.swift @@ -8,29 +8,28 @@ import StreamCore public final class BookmarkFolderList: Sendable { @MainActor private let stateBuilder: StateBuilder private let bookmarksRepository: BookmarksRepository - + init(query: BookmarkFoldersQuery, client: FeedsClient) { bookmarksRepository = client.bookmarksRepository self.query = query - let events = client.eventsMiddleware - stateBuilder = StateBuilder { BookmarkFolderListState(query: query, events: events) } + let eventPublisher = client.stateLayerEventPublisher + stateBuilder = StateBuilder { BookmarkFolderListState(query: query, eventPublisher: eventPublisher) } } public let query: BookmarkFoldersQuery - + // MARK: - Accessing the State - + /// An observable object representing the current state of the bookmark list. @MainActor public var state: BookmarkFolderListState { stateBuilder.state } - + // MARK: - Paginating the List of BookmarkFolders - - @discardableResult - public func get() async throws -> [BookmarkFolderData] { + + @discardableResult public func get() async throws -> [BookmarkFolderData] { try await queryBookmarkFolders(with: query) } - - public func queryMoreBookmarkFolders(limit: Int? = nil) async throws -> [BookmarkFolderData] { + + @discardableResult public func queryMoreBookmarkFolders(limit: Int? = nil) async throws -> [BookmarkFolderData] { let nextQuery: BookmarkFoldersQuery? = await state.access { state in guard let next = state.pagination?.next else { return nil } return BookmarkFoldersQuery( @@ -44,9 +43,9 @@ public final class BookmarkFolderList: Sendable { guard let nextQuery else { return [] } return try await queryBookmarkFolders(with: nextQuery) } - + // MARK: - Private - + private func queryBookmarkFolders(with query: BookmarkFoldersQuery) async throws -> [BookmarkFolderData] { let result = try await bookmarksRepository.queryBookmarkFolders(with: query) await state.didPaginate( diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState+Observer.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState+Observer.swift deleted file mode 100644 index 3b72032..0000000 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState+Observer.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -extension BookmarkFolderListState { - final class WebSocketObserver: WSEventsSubscriber { - private let handlers: BookmarkFolderListState.ChangeHandlers - - init(subscribing events: WSEventsSubscribing, handlers: BookmarkFolderListState.ChangeHandlers) { - self.handlers = handlers - events.add(subscriber: self) - } - - // MARK: - Event Subscription - - func onEvent(_ event: any Event) async { - switch event { - case let event as BookmarkFolderDeletedEvent: - await handlers.bookmarkFolderRemoved(event.bookmarkFolder.id) - case let event as BookmarkFolderUpdatedEvent: - await handlers.bookmarkFolderUpdated(event.bookmarkFolder.toModel()) - default: - break - } - } - } -} diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift index 0bc9cbc..5630596 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFolderListState.swift @@ -6,31 +6,30 @@ import Combine import Foundation import StreamCore -@MainActor public class BookmarkFolderListState: ObservableObject { - private var webSocketObserver: WebSocketObserver? - lazy var changeHandlers: ChangeHandlers = makeChangeHandlers() - - init(query: BookmarkFoldersQuery, events: WSEventsSubscribing) { +@MainActor public final class BookmarkFolderListState: ObservableObject, StateAccessing { + private var eventSubscription: StateLayerEventPublisher.Subscription? + + init(query: BookmarkFoldersQuery, eventPublisher: StateLayerEventPublisher) { self.query = query - webSocketObserver = WebSocketObserver(subscribing: events, handlers: changeHandlers) + subscribe(to: eventPublisher) } - + public let query: BookmarkFoldersQuery - + /// All the paginated folders. @Published public private(set) var folders: [BookmarkFolderData] = [] - + // MARK: - Pagination State - + /// Last pagination information. public private(set) var pagination: PaginationData? - + /// Indicates whether there are more bookmark folders available to load. public var canLoadMore: Bool { pagination?.next != nil } - + /// The configuration used for the last query. private(set) var queryConfig: QueryConfiguration? - + var bookmarksSorting: [Sort] { if let sort = queryConfig?.sort, !sort.isEmpty { return sort @@ -42,26 +41,32 @@ import StreamCore // MARK: - Updating the State extension BookmarkFolderListState { - struct ChangeHandlers { - let bookmarkFolderRemoved: @MainActor (String) -> Void - let bookmarkFolderUpdated: @MainActor (BookmarkFolderData) -> Void - } - - private func makeChangeHandlers() -> ChangeHandlers { - ChangeHandlers( - bookmarkFolderRemoved: { [weak self] id in - self?.folders.remove(byId: id) - }, - bookmarkFolderUpdated: { [weak self] folder in - self?.folders.replace(byId: folder) + 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 + if matches { + state.folders.sortedReplace(folder, nesting: nil, sorting: state.bookmarksSorting) + } else { + state.folders.remove(byId: folder.id) + } + } + default: + break } - ) - } - - 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 7c2c795..8461bdc 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFoldersQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkFoldersQuery.swift @@ -68,18 +68,9 @@ public struct BookmarkFoldersFilterField: FilterFieldRepresentable, Sendable { self.rawValue = rawValue matcher = AnyFilterMatcher(localValue: localValue) } - - init(_ codingKey: BookmarkFolderResponse.CodingKeys, localValue: @escaping @Sendable (Model) -> Value?) where Value: FilterValue { - self.init(codingKey.rawValue, localValue: localValue) - } } extension BookmarkFoldersFilterField { - /// Filter by the unique identifier of the bookmark folder. - /// - /// **Supported operators:** `.equal`, `.in` - public static let folderId = Self("folder_id", localValue: \.id) - /// Filter by the name of the bookmark folder. /// /// **Supported operators:** `.equal`, `.in`, `.contains` @@ -88,17 +79,17 @@ extension BookmarkFoldersFilterField { /// Filter by the creation timestamp of the bookmark folder. /// /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` - public static let createdAt = Self(.createdAt, localValue: \.createdAt) + public static let createdAt = Self("created_at", localValue: \.createdAt) /// Filter by the last update timestamp of the bookmark folder. /// /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` - public static let updatedAt = Self(.updatedAt, localValue: \.updatedAt) + public static let updatedAt = Self("updated_at", localValue: \.updatedAt) /// 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. @@ -174,7 +165,9 @@ public struct BookmarkFoldersSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension BookmarkFoldersSortField { /// Sort by the creation timestamp of the bookmark folder. /// This field allows sorting bookmark folders by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -195,7 +188,7 @@ extension Sort where Field == BookmarkFoldersSortField { extension BookmarkFoldersQuery { func toRequest() -> QueryBookmarkFoldersRequest { QueryBookmarkFoldersRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift index 92368d6..ae45fff 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkList.swift @@ -8,29 +8,32 @@ import StreamCore public final class BookmarkList: Sendable { @MainActor private let stateBuilder: StateBuilder private let bookmarksRepository: BookmarksRepository - + private let eventPublisher: StateLayerEventPublisher + private let ownCapabilitiesRepository: OwnCapabilitiesRepository + init(query: BookmarksQuery, client: FeedsClient) { bookmarksRepository = client.bookmarksRepository + eventPublisher = client.stateLayerEventPublisher + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query - let events = client.eventsMiddleware - stateBuilder = StateBuilder { BookmarkListState(query: query, events: events) } + let eventPublisher = client.stateLayerEventPublisher + stateBuilder = StateBuilder { BookmarkListState(query: query, eventPublisher: eventPublisher) } } public let query: BookmarksQuery - + // MARK: - Accessing the State - + /// An observable object representing the current state of the bookmark list. @MainActor public var state: BookmarkListState { stateBuilder.state } - + // MARK: - Paginating the List of Bookmarks - - @discardableResult - public func get() async throws -> [BookmarkData] { + + @discardableResult public func get() async throws -> [BookmarkData] { try await queryBookmarks(with: query) } - - public func queryMoreBookmarks(limit: Int? = nil) async throws -> [BookmarkData] { + + @discardableResult public func queryMoreBookmarks(limit: Int? = nil) async throws -> [BookmarkData] { let nextQuery: BookmarksQuery? = await state.access { state in guard let next = state.pagination?.next else { return nil } return BookmarksQuery( @@ -44,15 +47,18 @@ public final class BookmarkList: Sendable { guard let nextQuery else { return [] } return try await queryBookmarks(with: nextQuery) } - + // MARK: - Private - + private func queryBookmarks(with query: BookmarksQuery) async throws -> [BookmarkData] { let result = try await bookmarksRepository.queryBookmarks(with: query) await state.didPaginate( with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models.compactMap(\.activity.currentFeed)) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.models } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState+Observer.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState+Observer.swift deleted file mode 100644 index 85a8019..0000000 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState+Observer.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -extension BookmarkListState { - final class WebSocketObserver: WSEventsSubscriber { - private let handlers: BookmarkListState.ChangeHandlers - - init(subscribing events: WSEventsSubscribing, handlers: BookmarkListState.ChangeHandlers) { - self.handlers = handlers - events.add(subscriber: self) - } - - // MARK: - Event Subscription - - func onEvent(_ event: any Event) async { - switch event { - case let event as BookmarkFolderDeletedEvent: - await handlers.bookmarkFolderRemoved(event.bookmarkFolder.id) - case let event as BookmarkFolderUpdatedEvent: - await handlers.bookmarkFolderUpdated(event.bookmarkFolder.toModel()) - case let event as BookmarkUpdatedEvent: - await handlers.bookmarkUpdated(event.bookmark.toModel()) - default: - break - } - } - } -} diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift index 0cc731f..176f669 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarkListState.swift @@ -6,31 +6,30 @@ import Combine import Foundation import StreamCore -@MainActor public class BookmarkListState: ObservableObject { - private var webSocketObserver: WebSocketObserver? - lazy var changeHandlers: ChangeHandlers = makeChangeHandlers() - - init(query: BookmarksQuery, events: WSEventsSubscribing) { +@MainActor public final class BookmarkListState: ObservableObject, StateAccessing { + private var eventSubscription: StateLayerEventPublisher.Subscription? + + init(query: BookmarksQuery, eventPublisher: StateLayerEventPublisher) { self.query = query - webSocketObserver = WebSocketObserver(subscribing: events, handlers: changeHandlers) + subscribe(to: eventPublisher) } - + public let query: BookmarksQuery - + /// All the paginated bookmarks. @Published public private(set) var bookmarks: [BookmarkData] = [] - + // MARK: - Pagination State - + /// Last pagination information. public private(set) var pagination: PaginationData? - + /// Indicates whether there are more bookmarks available to load. public var canLoadMore: Bool { pagination?.next != nil } - + /// The configuration used for the last query. private(set) var queryConfig: QueryConfiguration? - + var bookmarkFoldersSorting: [Sort] { if let sort = queryConfig?.sort, !sort.isEmpty { return sort @@ -42,41 +41,56 @@ import StreamCore // MARK: - Updating the State extension BookmarkListState { - /// Handlers for various state change events. - /// - /// These handlers are called when WebSocket events are received and automatically update the state accordingly. - struct ChangeHandlers { - let bookmarkFolderRemoved: @MainActor (String) -> Void - let bookmarkFolderUpdated: @MainActor (BookmarkFolderData) -> Void - let bookmarkUpdated: @MainActor (BookmarkData) -> Void - } - - private func makeChangeHandlers() -> ChangeHandlers { - ChangeHandlers( - bookmarkFolderRemoved: { [weak self] folderId in - self?.updateBookmarkFolder(with: folderId, changes: { $0.folder = nil }) - }, - bookmarkFolderUpdated: { [weak self] folder in - self?.updateBookmarkFolder(with: folder.id, changes: { $0.folder = folder }) - }, - bookmarkUpdated: { [weak self] feed in - // Only update, do not insert - self?.bookmarks.replace(byId: feed) + 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): + await self?.access { state in + state.bookmarks.updateAll( + where: { $0.folder?.id == folder.id }, + changes: { $0.folder = nil } + ) + } + case .bookmarkFolderUpdated(let folder): + await self?.access { state in + state.bookmarks.updateAll( + where: { $0.folder?.id == folder.id }, + changes: { $0.folder = folder } + ) + } + case .bookmarkUpdated(let bookmark): + let matches = matchesQuery(bookmark) + await self?.access { state in + if matches { + state.bookmarks.sortedReplace(bookmark, nesting: nil, sorting: state.bookmarkFoldersSorting) + } else { + state.bookmarks.remove(byId: bookmark.id) + } + } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.bookmarks.updateAll( + where: { capabilitiesMap.contains($0.activity.currentFeed?.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } + default: + break } - ) + } } - + private func updateBookmarkFolder(with id: String, changes: (inout BookmarkData) -> Void) { guard let index = bookmarks.firstIndex(where: { $0.folder?.id == id }) else { return } var bookmark = bookmarks[index] changes(&bookmark) 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/BookmarksQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarksQuery.swift index 99ec73e..e1b96d0 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarksQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/BookmarksQuery.swift @@ -68,10 +68,6 @@ public struct BookmarksFilterField: FilterFieldRepresentable, Sendable { self.rawValue = rawValue matcher = AnyFilterMatcher(localValue: localValue) } - - init(_ codingKey: BookmarkResponse.CodingKeys, localValue: @escaping @Sendable (Model) -> Value?) where Value: FilterValue { - self.init(codingKey.rawValue, localValue: localValue) - } } extension BookmarksFilterField { @@ -93,12 +89,12 @@ extension BookmarksFilterField { /// Filter by the creation timestamp of the bookmark. /// /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` - public static let createdAt = Self(.createdAt, localValue: \.createdAt) + public static let createdAt = Self("created_at", localValue: \.createdAt) /// Filter by the last update timestamp of the bookmark. /// /// **Supported operators:** `.equal`, `.greaterThan`, `.lessThan`, `.greaterThanOrEqual`, `.lessThanOrEqual` - public static let updatedAt = Self(.updatedAt, localValue: \.updatedAt) + public static let updatedAt = Self("updated_at", localValue: \.updatedAt) } /// A filter that can be applied to bookmarks queries. @@ -174,7 +170,9 @@ public struct BookmarksSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension BookmarksSortField { /// Sort by the creation timestamp of the bookmark. /// This field allows sorting bookmarks by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -195,7 +193,7 @@ extension Sort where Field == BookmarksSortField { extension BookmarksQuery { func toRequest() -> QueryBookmarksRequest { QueryBookmarksRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentListState.swift index f484b99..525cb73 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 a80038b..a38b338 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/CommentReactionsQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReactionsQuery.swift index d067220..a47a31b 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReactionsQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReactionsQuery.swift @@ -168,7 +168,9 @@ public struct CommentReactionsSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension CommentReactionsSortField { /// Sort by the creation timestamp of the reaction. /// This field allows sorting reactions by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -185,7 +187,7 @@ extension Sort where Field == CommentReactionsSortField { extension CommentReactionsQuery { func toRequest() -> QueryCommentReactionsRequest { QueryCommentReactionsRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReplyListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentReplyListState.swift index d898f0f..159708e 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/CommentsQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentsQuery.swift index dda834d..370f1f9 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentsQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/CommentsQuery.swift @@ -92,7 +92,7 @@ extension CommentsFilterField { /// Filter by the type of object the comment belongs to (e.g., "activity", "post"). /// - /// **Supported operators:** `.equal`, `.notEqual`, `.in` + /// **Supported operators:** `.equal`, `.in` public static let objectType = Self("object_type", localValue: \.objectType) /// Filter by the ID of the object the comment belongs to. @@ -258,7 +258,7 @@ extension CommentsSort { extension CommentsQuery { func toRequest() -> QueryCommentsRequest { QueryCommentsRequest( - filter: filter?.toRawJSON() ?? [:], + filter: filter?.toRawJSONDictionary() ?? [:], limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift index a00b1e4..6c434ad 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedList.swift @@ -2,35 +2,46 @@ // 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 - - init(query: FeedsQuery, client: FeedsClient) { + private let disposableBag = DisposableBag() + private let eventPublisher: StateLayerEventPublisher + private let ownCapabilitiesRepository: OwnCapabilitiesRepository + private let refetchSubject = AllocatedUnfairLock(PassthroughSubject()) + private let refetchDelay: Int + @MainActor private let stateBuilder: StateBuilder + + init(query: FeedsQuery, client: FeedsClient, refetchDelay: Int = 5) { + eventPublisher = client.stateLayerEventPublisher feedsRepository = client.feedsRepository + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query - let events = client.eventsMiddleware - stateBuilder = StateBuilder { FeedListState(query: query, events: events) } + self.refetchDelay = refetchDelay + let eventPublisher = client.stateLayerEventPublisher + stateBuilder = StateBuilder { [refetchSubject] in + FeedListState(query: query, eventPublisher: eventPublisher, refetchSubject: refetchSubject) + } + subscribeToRefetch() } public let query: FeedsQuery - + // MARK: - Accessing the State - + /// An observable object representing the current state of the feed list. @MainActor public var state: FeedListState { stateBuilder.state } - + // MARK: - Paginating the List of Feeds - - @discardableResult - public func get() async throws -> [FeedData] { + + @discardableResult public func get() async throws -> [FeedData] { try await queryFeeds(with: query) } - - public func queryMoreFeeds(limit: Int? = nil) async throws -> [FeedData] { + + @discardableResult public func queryMoreFeeds(limit: Int? = nil) async throws -> [FeedData] { let nextQuery: FeedsQuery? = await state.access { state in guard let next = state.pagination?.next else { return nil } return FeedsQuery( @@ -45,15 +56,57 @@ public final class FeedList: Sendable { guard let nextQuery else { return [] } return try await queryFeeds(with: nextQuery) } - + // MARK: - Private - + private func queryFeeds(with query: FeedsQuery) async throws -> [FeedData] { let result = try await feedsRepository.queryFeeds(with: query) await state.didPaginate( with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } 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+Observer.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState+Observer.swift deleted file mode 100644 index da5f2e9..0000000 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState+Observer.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -extension FeedListState { - final class WebSocketObserver: WSEventsSubscriber { - private let handlers: FeedListState.ChangeHandlers - - init(subscribing events: WSEventsSubscribing, handlers: FeedListState.ChangeHandlers) { - self.handlers = handlers - events.add(subscriber: self) - } - - // MARK: - Event Subscription - - func onEvent(_ event: any Event) async { - switch event { - case let event as FeedUpdatedEvent: - await handlers.feedUpdated(event.feed.toModel()) - default: - break - } - } - } -} diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift index 9123f79..30f5a8e 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FeedListState.swift @@ -6,31 +6,34 @@ import Combine import Foundation import StreamCore -@MainActor public class FeedListState: ObservableObject { - private var webSocketObserver: WebSocketObserver? - lazy var changeHandlers: ChangeHandlers = makeChangeHandlers() - - init(query: FeedsQuery, events: WSEventsSubscribing) { +@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, refetchSubject: AllocatedUnfairLock>) { self.query = query - webSocketObserver = WebSocketObserver(subscribing: events, handlers: changeHandlers) + self.canFilterLocally = query.canFilterLocally + self.refetchSubject = refetchSubject + subscribe(to: eventPublisher) } - + public let query: FeedsQuery - + /// All the paginated feeds. @Published public private(set) var feeds: [FeedData] = [] - + // MARK: - Pagination State - + /// Last pagination information. public private(set) var pagination: PaginationData? - + /// Indicates whether there are more feeds available to load. public var canLoadMore: Bool { pagination?.next != nil } - + /// The configuration used for the last activities query. private(set) var queryConfig: QueryConfiguration? - + var feedsSorting: [Sort] { if let sort = queryConfig?.sort, !sort.isEmpty { return sort @@ -42,26 +45,71 @@ import StreamCore // MARK: - Updating the State extension FeedListState { - /// Handlers for various state change events. - /// - /// These handlers are called when WebSocket events are received and automatically update the state accordingly. - struct ChangeHandlers { - let feedUpdated: @MainActor (FeedData) -> Void - } - - private func makeChangeHandlers() -> ChangeHandlers { - ChangeHandlers( - feedUpdated: { [weak self] feed in - // Only update, do not insert - self?.feeds.replace(byId: feed) + private func subscribe(to publisher: StateLayerEventPublisher) { + let matchesQuery: @Sendable (FeedData) -> Bool = { [query] feedData in + guard let filter = query.filter else { return true } + return filter.matches(feedData) + } + eventSubscription = publisher.subscribe { [weak self, canFilterLocally, refetchSubject] event in + switch event { + case .feedAdded(let feed, _): + 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, _): + guard let self else { return } + if canFilterLocally { + let matches = matchesQuery(feed) + await self.access { state in + if matches { + state.feeds.sortedUpdate( + feed, + nesting: nil, + sorting: state.feedsSorting.areInIncreasingOrder(), + changes: { existing in existing.merge(with: feed) } + ) + } 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() } + } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.feeds.updateAll( + where: { capabilitiesMap.contains($0.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) + } + default: + break } - ) - } - - func access(_ actions: @MainActor (FeedListState) -> T) -> T { - actions(self) + } } - + func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration @@ -70,4 +118,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 c38fc2f..728df3d 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. /// @@ -260,7 +260,9 @@ public struct FeedsSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension FeedsSortField { /// Sort by the creation timestamp of the feed. /// This field allows sorting feeds by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -296,7 +298,7 @@ extension FeedsQuery { /// - Returns: A `QueryFeedsRequest` object that can be sent to the API. func toRequest() -> QueryFeedsRequest { QueryFeedsRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, @@ -304,4 +306,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/FollowList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowList.swift index 4147090..f125546 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowList.swift @@ -7,10 +7,14 @@ import StreamCore public final class FollowList: Sendable { @MainActor private let stateBuilder: StateBuilder + private let eventPublisher: StateLayerEventPublisher private let feedsRepository: FeedsRepository + private let ownCapabilitiesRepository: OwnCapabilitiesRepository init(query: FollowsQuery, client: FeedsClient) { + eventPublisher = client.stateLayerEventPublisher feedsRepository = client.feedsRepository + ownCapabilitiesRepository = client.ownCapabilitiesRepository self.query = query let eventPublisher = client.stateLayerEventPublisher stateBuilder = StateBuilder { [eventPublisher] in @@ -57,6 +61,9 @@ public final class FollowList: Sendable { with: result, for: .init(filter: query.filter, sort: query.sort) ) + if let updated = ownCapabilitiesRepository.saveCapabilities(in: result.models.map(\.sourceFeed) + result.models.map(\.targetFeed)) { + await eventPublisher.sendEvent(.feedOwnCapabilitiesUpdated(updated)) + } return result.models } } diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowListState.swift index 281eb5e..29a7433 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,20 @@ 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) + } + } + case .feedOwnCapabilitiesUpdated(let capabilitiesMap): + await self?.access { state in + state.follows.updateAll( + where: { capabilitiesMap.contains($0.sourceFeed.feed) || capabilitiesMap.contains($0.targetFeed.feed) }, + changes: { $0.mergeFeedOwnCapabilities(from: capabilitiesMap) } + ) } default: break @@ -67,10 +79,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/FollowsQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowsQuery.swift index 793cb20..0516716 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowsQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/FollowsQuery.swift @@ -160,7 +160,9 @@ public struct FollowsSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension FollowsSortField { /// Sort by the creation timestamp of the follow relationship. /// This field allows sorting follows by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -177,7 +179,7 @@ extension Sort where Field == FollowsSortField { extension FollowsQuery { func toRequest() -> QueryFollowsRequest { QueryFollowsRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberList.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberList.swift index 8eef478..36b1938 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberList.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberList.swift @@ -13,12 +13,12 @@ import StreamCore public final class MemberList: Sendable { @MainActor private let stateBuilder: StateBuilder private let feedsRepository: FeedsRepository - + init(query: MembersQuery, client: FeedsClient) { feedsRepository = client.feedsRepository self.query = query - let events = client.eventsMiddleware - stateBuilder = StateBuilder { MemberListState(query: query, events: events) } + let eventPublisher = client.stateLayerEventPublisher + stateBuilder = StateBuilder { MemberListState(query: query, eventPublisher: eventPublisher) } } /// The query configuration used to fetch members. @@ -26,18 +26,18 @@ public final class MemberList: Sendable { /// This contains the feed ID, filters, sorting options, and pagination parameters /// that define which members are retrieved and how they are ordered. public let query: MembersQuery - + // MARK: - Accessing the State - + /// An observable object representing the current state of the member list. /// /// This property provides access to the current members, pagination state, /// and other state information. The state is automatically updated when /// new members are loaded or when real-time updates are received. @MainActor public var state: MemberListState { stateBuilder.state } - + // MARK: - Paginating the List of Members - + /// Fetches the initial list of members based on the current query configuration. /// /// This method loads the first page of members according to the query's filters, @@ -50,7 +50,7 @@ public final class MemberList: Sendable { public func get() async throws -> [FeedMemberData] { try await queryMembers(with: query) } - + /// Loads the next page of members if more are available. /// /// This method fetches additional members using the pagination information @@ -78,9 +78,9 @@ public final class MemberList: Sendable { guard let nextQuery else { return [] } return try await queryMembers(with: nextQuery) } - + // MARK: - Private - + private func queryMembers(with query: MembersQuery) async throws -> [FeedMemberData] { let response = try await feedsRepository.queryFeedMembers( feedGroupId: query.feed.group, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState+Observer.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState+Observer.swift deleted file mode 100644 index 86954ee..0000000 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState+Observer.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -extension MemberListState { - final class WebSocketObserver: WSEventsSubscriber { - private let handlers: MemberListState.ChangeHandlers - private let feed: FeedId - - init(feed: FeedId, subscribing events: WSEventsSubscribing, handlers: MemberListState.ChangeHandlers) { - self.handlers = handlers - self.feed = feed - events.add(subscriber: self) - } - - // MARK: - Event Subscription - - func onEvent(_ event: any Event) async { - switch event { - case let event as FeedMemberRemovedEvent: - guard event.fid == feed.rawValue else { return } - await handlers.memberRemoved(event.memberId) - case let event as FeedMemberUpdatedEvent: - guard event.fid == feed.rawValue else { return } - await handlers.memberUpdated(event.member.toModel()) - default: - break - } - } - } -} diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift index 82ec3e2..7a17fb7 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/MemberListState.swift @@ -11,48 +11,47 @@ 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 { - private var webSocketObserver: WebSocketObserver? - lazy var changeHandlers: ChangeHandlers = makeChangeHandlers() - - init(query: MembersQuery, events: WSEventsSubscribing) { +@MainActor public final class MemberListState: ObservableObject, StateAccessing { + private var eventSubscription: StateLayerEventPublisher.Subscription? + + init(query: MembersQuery, eventPublisher: StateLayerEventPublisher) { self.query = query - webSocketObserver = WebSocketObserver(feed: query.feed, subscribing: events, handlers: changeHandlers) + subscribe(to: eventPublisher) } - + /// The original query configuration used to fetch members. /// /// This contains the feed ID, filters, and sorting options that were used /// to create the initial member list. public let query: MembersQuery - + /// All the paginated members currently loaded. /// /// This array contains all members that have been fetched across multiple /// pagination requests. The members are automatically sorted according to /// the current sorting configuration. @Published public private(set) var members: [FeedMemberData] = [] - + // MARK: - Pagination State - + /// Last pagination information from the most recent request. /// /// Contains the `next` and `previous` cursor values that can be used /// to fetch additional pages of members. public private(set) var pagination: PaginationData? - + /// Indicates whether there are more members available to load. /// /// Returns `true` if there are additional members that can be fetched /// using the pagination information, `false` otherwise. public var canLoadMore: Bool { pagination?.next != nil } - + /// The configuration used for the last query. /// /// Contains the filter and sort parameters that were applied to the /// most recent member fetch operation. private(set) var queryConfig: QueryConfiguration? - + var membersSorting: [Sort] { if let sort = queryConfig?.sort, !sort.isEmpty { return sort @@ -64,33 +63,56 @@ import StreamCore // MARK: - Updating the State extension MemberListState { - struct ChangeHandlers { - let memberRemoved: @MainActor (String) -> Void - let memberUpdated: @MainActor (FeedMemberData) -> Void - } - - private func makeChangeHandlers() -> ChangeHandlers { - ChangeHandlers( - memberRemoved: { [weak self] memberId in - guard let index = self?.members.firstIndex(where: { $0.id == memberId }) else { return } - self?.members.remove(at: index) - }, - memberUpdated: { [weak self] member in - self?.members.replace(byId: member) + 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.toLocalFilterModel(feed: query.feed)) + } + eventSubscription = publisher.subscribe { [weak self, query] event in + switch event { + case .feedMemberAdded(let memberData, let eventFeedId): + guard eventFeedId == query.feed else { return } + guard matchesQuery(memberData) else { return } + await self?.access { state in + state.members.sortedInsert(memberData, sorting: state.membersSorting) + } + case .feedMemberDeleted(let memberId, let eventFeedId): + guard eventFeedId == query.feed else { return } + await self?.access { state in + state.members.remove(byId: memberId) + } + case .feedMemberUpdated(let memberData, let eventFeedId): + guard eventFeedId == query.feed else { return } + let matches = matchesQuery(memberData) + await self?.access { state in + 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: + break } - ) - } - - 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) members.remove(byIds: updates.removedIds) } - + func didPaginate( with response: PaginationResult, for queryConfig: QueryConfiguration diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/MembersQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/MembersQuery.swift index ad7e7c8..aa39ec1 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/MembersQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/MembersQuery.swift @@ -179,7 +179,9 @@ public struct MembersSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension MembersSortField { /// Sort by the creation timestamp of the member. /// This field allows sorting members by when they were added to the feed (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -207,7 +209,7 @@ extension MembersQuery { /// - Returns: A `QueryFeedMembersRequest` object that can be sent to the API. func toRequest() -> QueryFeedMembersRequest { QueryFeedMembersRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigListState.swift index 8a4981e..86e9b12 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/ModerationConfigsQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigsQuery.swift index ad548b3..453c5b4 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigsQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/ModerationConfigsQuery.swift @@ -181,15 +181,21 @@ public struct ModerationConfigsSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension ModerationConfigsSortField { /// Sort by the unique key of the configuration. /// This field allows sorting configurations by their key (alphabetical order). - public static let key = Self("id", localValue: \.key) + public static let key = Self("key", localValue: \.key) /// Sort by the creation timestamp of the configuration. /// This field allows sorting configurations by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) + /// Sort by the team associated with the configuration. + /// This field allows sorting configurations by team (alphabetical order). + public static let team = Self("team", localValue: \.team) + /// Sort by the last update timestamp of the configuration. /// This field allows sorting configurations by when they were last updated (newest/oldest first). public static let updatedAt = Self("updated_at", localValue: \.updatedAt) @@ -206,7 +212,7 @@ extension Sort where Field == ModerationConfigsSortField { extension ModerationConfigsQuery { func toRequest() -> QueryModerationConfigsRequest { QueryModerationConfigsRequest( - filter: filter?.toRawJSON(), + filter: filter?.toRawJSONDictionary(), limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollListState.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollListState.swift index 7e6a7a1..356373f 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 1dc1791..d3f8278 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/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVotesQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVotesQuery.swift index 733b600..f4c2573 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVotesQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollVotesQuery.swift @@ -191,7 +191,9 @@ public struct PollVotesSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension PollVotesSortField { /// Sort by the answer text of the poll option. /// This field allows sorting poll votes by the text content of the selected option. public static let answerText = Self("answer_text", localValue: { $0.answerText ?? "" }) @@ -220,7 +222,7 @@ extension Sort where Field == PollVotesSortField { extension PollVotesQuery { func toRequest() -> QueryPollVotesRequest { QueryPollVotesRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollsQuery.swift b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollsQuery.swift index b3d11c3..2f45be0 100644 --- a/Sources/StreamFeeds/StateLayer/PaginatedLists/PollsQuery.swift +++ b/Sources/StreamFeeds/StateLayer/PaginatedLists/PollsQuery.swift @@ -201,7 +201,9 @@ public struct PollsSortField: SortField { comparator = AnySortComparator(localValue: localValue) self.rawValue = rawValue } - +} + +extension PollsSortField { /// Sort by the creation timestamp of the poll. /// This field allows sorting polls by when they were created (newest/oldest first). public static let createdAt = Self("created_at", localValue: \.createdAt) @@ -210,10 +212,6 @@ public struct PollsSortField: SortField { /// This field allows sorting polls by when they were last updated (newest/oldest first). public static let updatedAt = Self("updated_at", localValue: \.updatedAt) - /// Sort by the number of votes the poll has received. - /// This field allows sorting polls by popularity (most/least voted). - public static let voteCount = Self("vote_count", localValue: \.voteCount) - /// Sort by the name of the poll. /// This field allows sorting polls alphabetically by name. public static let name = Self("name", localValue: \.name) @@ -241,7 +239,7 @@ extension PollsQuery { /// - Returns: A `QueryPollsRequest` object that can be sent to the API. func toRequest() -> QueryPollsRequest { QueryPollsRequest( - filter: filter.flatMap { $0.toRawJSON() }, + filter: filter.flatMap { $0.toRawJSONDictionary() }, limit: limit, next: next, prev: previous, diff --git a/Sources/StreamFeeds/StreamFeeds.h b/Sources/StreamFeeds/StreamFeeds.h deleted file mode 100644 index fecbad4..0000000 --- a/Sources/StreamFeeds/StreamFeeds.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -#import - -//! Project version number for StreamFeeds. -FOUNDATION_EXPORT double StreamFeedsVersionNumber; - -//! Project version string for StreamFeeds. -FOUNDATION_EXPORT const unsigned char StreamFeedsVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Sources/StreamFeeds/Utils/SystemEnvironment+Version.swift b/Sources/StreamFeeds/Utils/SystemEnvironment+Version.swift index 9ca0921..95a6a7f 100644 --- a/Sources/StreamFeeds/Utils/SystemEnvironment+Version.swift +++ b/Sources/StreamFeeds/Utils/SystemEnvironment+Version.swift @@ -6,5 +6,5 @@ import Foundation extension SystemEnvironment { /// A Stream Feeds version. - public static let version: String = "0.4.0" + public static let version: String = "0.5.0" } diff --git a/Sources/StreamFeeds/generated/feeds/APIs/DefaultAPI.swift b/Sources/StreamFeeds/generated/feeds/APIs/DefaultAPI.swift index b12ab24..884d016 100644 --- a/Sources/StreamFeeds/generated/feeds/APIs/DefaultAPI.swift +++ b/Sources/StreamFeeds/generated/feeds/APIs/DefaultAPI.swift @@ -390,7 +390,7 @@ open class DefaultAPI: DefaultAPIEndpoints, @unchecked Sendable { } } - open func addReaction(activityId: String, addReactionRequest: AddReactionRequest) async throws -> AddReactionResponse { + open func addActivityReaction(activityId: String, addReactionRequest: AddReactionRequest) async throws -> AddReactionResponse { var path = "/api/v2/feeds/activities/{activity_id}/reactions" let activityIdPreEscape = "\(APIHelper.mapValueToPathItem(activityId))" @@ -1691,6 +1691,19 @@ open class DefaultAPI: DefaultAPIEndpoints, @unchecked Sendable { try self.jsonDecoder.decode(UnblockUsersResponse.self, from: $0) } } + + open func ownCapabilitiesBatch(ownCapabilitiesBatchRequest: OwnCapabilitiesBatchRequest) async throws -> OwnCapabilitiesBatchResponse { + let path = "/api/v2/feeds/feeds/own_capabilities/batch" + + let urlRequest = try makeRequest( + uriPath: path, + httpMethod: "POST", + request: ownCapabilitiesBatchRequest + ) + return try await send(request: urlRequest) { + try self.jsonDecoder.decode(OwnCapabilitiesBatchResponse.self, from: $0) + } + } } protocol DefaultAPIEndpoints { @@ -1730,7 +1743,7 @@ protocol DefaultAPIEndpoints { func deletePollVote(activityId: String, pollId: String, voteId: String, userId: String?) async throws -> PollVoteResponse - func addReaction(activityId: String, addReactionRequest: AddReactionRequest) async throws -> AddReactionResponse + func addActivityReaction(activityId: String, addReactionRequest: AddReactionRequest) async throws -> AddReactionResponse func queryActivityReactions(activityId: String, queryActivityReactionsRequest: QueryActivityReactionsRequest) async throws -> QueryActivityReactionsResponse @@ -1887,4 +1900,6 @@ protocol DefaultAPIEndpoints { func updateLiveLocation(updateLiveLocationRequest: UpdateLiveLocationRequest) async throws -> SharedLocationResponse func unblockUsers(unblockUsersRequest: UnblockUsersRequest) async throws -> UnblockUsersResponse + + func ownCapabilitiesBatch(ownCapabilitiesBatchRequest: OwnCapabilitiesBatchRequest) async throws -> OwnCapabilitiesBatchResponse } diff --git a/Sources/StreamFeeds/generated/feeds/models/APNS.swift b/Sources/StreamFeeds/generated/feeds/models/APNS.swift deleted file mode 100644 index 5e4004c..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/APNS.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class APNS: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var body: String - public var contentAvailable: Int? - public var data: [String: RawJSON]? - public var mutableContent: Int? - public var sound: String? - public var title: String - - public init(body: String, contentAvailable: Int? = nil, data: [String: RawJSON]? = nil, mutableContent: Int? = nil, sound: String? = nil, title: String) { - self.body = body - self.contentAvailable = contentAvailable - self.data = data - self.mutableContent = mutableContent - self.sound = sound - self.title = title - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case body - case contentAvailable = "content-available" - case data - case mutableContent = "mutable-content" - case sound - case title - } - - public static func == (lhs: APNS, rhs: APNS) -> Bool { - lhs.body == rhs.body && - lhs.contentAvailable == rhs.contentAvailable && - lhs.data == rhs.data && - lhs.mutableContent == rhs.mutableContent && - lhs.sound == rhs.sound && - lhs.title == rhs.title - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(body) - hasher.combine(contentAvailable) - hasher.combine(data) - hasher.combine(mutableContent) - hasher.combine(sound) - hasher.combine(title) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/ActionLog.swift b/Sources/StreamFeeds/generated/feeds/models/ActionLog.swift deleted file mode 100644 index e7560ae..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/ActionLog.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class ActionLog: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var createdAt: Date - public var custom: [String: RawJSON] - public var id: String - public var reason: String - public var reporterType: String - public var reviewQueueItem: ReviewQueueItem? - public var reviewQueueItemId: String - public var targetUser: User? - public var targetUserId: String - public var type: String - public var user: User? - - public init(createdAt: Date, custom: [String: RawJSON], id: String, reason: String, reporterType: String, reviewQueueItem: ReviewQueueItem? = nil, reviewQueueItemId: String, targetUser: User? = nil, targetUserId: String, type: String, user: User? = nil) { - self.createdAt = createdAt - self.custom = custom - self.id = id - self.reason = reason - self.reporterType = reporterType - self.reviewQueueItem = reviewQueueItem - self.reviewQueueItemId = reviewQueueItemId - self.targetUser = targetUser - self.targetUserId = targetUserId - self.type = type - self.user = user - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case createdAt = "created_at" - case custom - case id - case reason - case reporterType = "reporter_type" - case reviewQueueItem = "review_queue_item" - case reviewQueueItemId = "review_queue_item_id" - case targetUser = "target_user" - case targetUserId = "target_user_id" - case type - case user - } - - public static func == (lhs: ActionLog, rhs: ActionLog) -> Bool { - lhs.createdAt == rhs.createdAt && - lhs.custom == rhs.custom && - lhs.id == rhs.id && - lhs.reason == rhs.reason && - lhs.reporterType == rhs.reporterType && - lhs.reviewQueueItem == rhs.reviewQueueItem && - lhs.reviewQueueItemId == rhs.reviewQueueItemId && - lhs.targetUser == rhs.targetUser && - lhs.targetUserId == rhs.targetUserId && - lhs.type == rhs.type && - lhs.user == rhs.user - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(createdAt) - hasher.combine(custom) - hasher.combine(id) - hasher.combine(reason) - hasher.combine(reporterType) - hasher.combine(reviewQueueItem) - hasher.combine(reviewQueueItemId) - hasher.combine(targetUser) - hasher.combine(targetUserId) - hasher.combine(type) - hasher.combine(user) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/ActionLogResponse.swift b/Sources/StreamFeeds/generated/feeds/models/ActionLogResponse.swift index 59548ef..a3ddcc7 100644 --- a/Sources/StreamFeeds/generated/feeds/models/ActionLogResponse.swift +++ b/Sources/StreamFeeds/generated/feeds/models/ActionLogResponse.swift @@ -6,6 +6,7 @@ import Foundation import StreamCore public final class ActionLogResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { + public var aiProviders: [String] public var createdAt: Date public var custom: [String: RawJSON] public var id: String @@ -17,7 +18,8 @@ public final class ActionLogResponse: @unchecked Sendable, Codable, JSONEncodabl public var user: UserResponse? public var userId: String - public init(createdAt: Date, custom: [String: RawJSON], id: String, reason: String, reviewQueueItem: ReviewQueueItemResponse? = nil, targetUser: UserResponse? = nil, targetUserId: String, type: String, user: UserResponse? = nil, userId: String) { + public init(aiProviders: [String], createdAt: Date, custom: [String: RawJSON], id: String, reason: String, reviewQueueItem: ReviewQueueItemResponse? = nil, targetUser: UserResponse? = nil, targetUserId: String, type: String, user: UserResponse? = nil, userId: String) { + self.aiProviders = aiProviders self.createdAt = createdAt self.custom = custom self.id = id @@ -31,6 +33,7 @@ public final class ActionLogResponse: @unchecked Sendable, Codable, JSONEncodabl } public enum CodingKeys: String, CodingKey, CaseIterable { + case aiProviders = "ai_providers" case createdAt = "created_at" case custom case id @@ -44,7 +47,8 @@ public final class ActionLogResponse: @unchecked Sendable, Codable, JSONEncodabl } public static func == (lhs: ActionLogResponse, rhs: ActionLogResponse) -> Bool { - lhs.createdAt == rhs.createdAt && + lhs.aiProviders == rhs.aiProviders && + lhs.createdAt == rhs.createdAt && lhs.custom == rhs.custom && lhs.id == rhs.id && lhs.reason == rhs.reason && @@ -57,6 +61,7 @@ public final class ActionLogResponse: @unchecked Sendable, Codable, JSONEncodabl } public func hash(into hasher: inout Hasher) { + hasher.combine(aiProviders) hasher.combine(createdAt) hasher.combine(custom) hasher.combine(id) diff --git a/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackEvent.swift b/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackEvent.swift new file mode 100644 index 0000000..79349d0 --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackEvent.swift @@ -0,0 +1,50 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +public final class ActivityFeedbackEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { + public var activityFeedback: ActivityFeedbackEventPayload + public var createdAt: Date + public var custom: [String: RawJSON] + public var receivedAt: Date? + public var type: String = "feeds.activity.feedback" + public var user: UserResponseCommonFields? + + public init(activityFeedback: ActivityFeedbackEventPayload, createdAt: Date, custom: [String: RawJSON], receivedAt: Date? = nil, user: UserResponseCommonFields? = nil) { + self.activityFeedback = activityFeedback + self.createdAt = createdAt + self.custom = custom + self.receivedAt = receivedAt + self.user = user + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case activityFeedback = "activity_feedback" + case createdAt = "created_at" + case custom + case receivedAt = "received_at" + case type + case user + } + + public static func == (lhs: ActivityFeedbackEvent, rhs: ActivityFeedbackEvent) -> Bool { + lhs.activityFeedback == rhs.activityFeedback && + lhs.createdAt == rhs.createdAt && + lhs.custom == rhs.custom && + lhs.receivedAt == rhs.receivedAt && + lhs.type == rhs.type && + lhs.user == rhs.user + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(activityFeedback) + hasher.combine(createdAt) + hasher.combine(custom) + hasher.combine(receivedAt) + hasher.combine(type) + hasher.combine(user) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackEventPayload.swift b/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackEventPayload.swift new file mode 100644 index 0000000..3742ba8 --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackEventPayload.swift @@ -0,0 +1,68 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +public final class ActivityFeedbackEventPayload: @unchecked Sendable, Codable, JSONEncodable, Hashable { + + public enum ActivityFeedbackEventPayloadAction: String, Sendable, Codable, CaseIterable { + case hide = "hide" + case showLess = "show_less" + case showMore = "show_more" + case unknown = "_unknown" + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let decodedValue = try? container.decode(String.self), + let value = Self(rawValue: decodedValue) { + self = value + } else { + self = .unknown + } + } + } + public var action: ActivityFeedbackEventPayloadAction + public var activityId: String + public var createdAt: Date + public var updatedAt: Date + public var user: UserResponse + public var value: String + + public init(action: ActivityFeedbackEventPayloadAction, activityId: String, createdAt: Date, updatedAt: Date, user: UserResponse, value: String) { + self.action = action + self.activityId = activityId + self.createdAt = createdAt + self.updatedAt = updatedAt + self.user = user + self.value = value + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case action + case activityId = "activity_id" + case createdAt = "created_at" + case updatedAt = "updated_at" + case user + case value + } + + public static func == (lhs: ActivityFeedbackEventPayload, rhs: ActivityFeedbackEventPayload) -> Bool { + lhs.action == rhs.action && + lhs.activityId == rhs.activityId && + lhs.createdAt == rhs.createdAt && + lhs.updatedAt == rhs.updatedAt && + lhs.user == rhs.user && + lhs.value == rhs.value + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(action) + hasher.combine(activityId) + hasher.combine(createdAt) + hasher.combine(updatedAt) + hasher.combine(user) + hasher.combine(value) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackRequest.swift b/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackRequest.swift index 0ca43cc..29b0d26 100644 --- a/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackRequest.swift +++ b/Sources/StreamFeeds/generated/feeds/models/ActivityFeedbackRequest.swift @@ -7,40 +7,30 @@ import StreamCore public final class ActivityFeedbackRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { public var hide: Bool? - public var muteUser: Bool? - public var reason: String? - public var report: Bool? public var showLess: Bool? + public var showMore: Bool? - public init(hide: Bool? = nil, muteUser: Bool? = nil, reason: String? = nil, report: Bool? = nil, showLess: Bool? = nil) { + public init(hide: Bool? = nil, showLess: Bool? = nil, showMore: Bool? = nil) { self.hide = hide - self.muteUser = muteUser - self.reason = reason - self.report = report self.showLess = showLess + self.showMore = showMore } public enum CodingKeys: String, CodingKey, CaseIterable { case hide - case muteUser = "mute_user" - case reason - case report case showLess = "show_less" + case showMore = "show_more" } public static func == (lhs: ActivityFeedbackRequest, rhs: ActivityFeedbackRequest) -> Bool { lhs.hide == rhs.hide && - lhs.muteUser == rhs.muteUser && - lhs.reason == rhs.reason && - lhs.report == rhs.report && - lhs.showLess == rhs.showLess + lhs.showLess == rhs.showLess && + lhs.showMore == rhs.showMore } public func hash(into hasher: inout Hasher) { hasher.combine(hide) - hasher.combine(muteUser) - hasher.combine(reason) - hasher.combine(report) hasher.combine(showLess) + hasher.combine(showMore) } } diff --git a/Sources/StreamFeeds/generated/feeds/models/ActivitySelectorConfig.swift b/Sources/StreamFeeds/generated/feeds/models/ActivitySelectorConfig.swift index 17265db..90fa456 100644 --- a/Sources/StreamFeeds/generated/feeds/models/ActivitySelectorConfig.swift +++ b/Sources/StreamFeeds/generated/feeds/models/ActivitySelectorConfig.swift @@ -7,13 +7,15 @@ import StreamCore public final class ActivitySelectorConfig: @unchecked Sendable, Codable, JSONEncodable, Hashable { public var cutoffTime: Date + public var cutoffWindow: String? public var filter: [String: RawJSON]? public var minPopularity: Int? public var sort: [SortParam]? public var type: String? - public init(cutoffTime: Date, filter: [String: RawJSON]? = nil, minPopularity: Int? = nil, sort: [SortParam]? = nil) { + public init(cutoffTime: Date, cutoffWindow: String? = nil, filter: [String: RawJSON]? = nil, minPopularity: Int? = nil, sort: [SortParam]? = nil) { self.cutoffTime = cutoffTime + self.cutoffWindow = cutoffWindow self.filter = filter self.minPopularity = minPopularity self.sort = sort @@ -21,22 +23,25 @@ public final class ActivitySelectorConfig: @unchecked Sendable, Codable, JSONEnc public enum CodingKeys: String, CodingKey, CaseIterable { case cutoffTime = "cutoff_time" + case cutoffWindow = "cutoff_window" case filter case minPopularity = "min_popularity" case sort case type - } +} public static func == (lhs: ActivitySelectorConfig, rhs: ActivitySelectorConfig) -> Bool { lhs.cutoffTime == rhs.cutoffTime && - lhs.filter == rhs.filter && - lhs.minPopularity == rhs.minPopularity && - lhs.sort == rhs.sort && - lhs.type == rhs.type + lhs.cutoffWindow == rhs.cutoffWindow && + lhs.filter == rhs.filter && + lhs.minPopularity == rhs.minPopularity && + lhs.sort == rhs.sort && + lhs.type == rhs.type } public func hash(into hasher: inout Hasher) { hasher.combine(cutoffTime) + hasher.combine(cutoffWindow) hasher.combine(filter) hasher.combine(minPopularity) hasher.combine(sort) diff --git a/Sources/StreamFeeds/generated/feeds/models/AggregatedActivityResponse.swift b/Sources/StreamFeeds/generated/feeds/models/AggregatedActivityResponse.swift index 213bf96..213b904 100644 --- a/Sources/StreamFeeds/generated/feeds/models/AggregatedActivityResponse.swift +++ b/Sources/StreamFeeds/generated/feeds/models/AggregatedActivityResponse.swift @@ -10,16 +10,18 @@ public final class AggregatedActivityResponse: @unchecked Sendable, Codable, JSO public var activityCount: Int public var createdAt: Date public var group: String + public var isWatched: Bool? public var score: Float public var updatedAt: Date public var userCount: Int public var userCountTruncated: Bool - public init(activities: [ActivityResponse], activityCount: Int, createdAt: Date, group: String, score: Float, updatedAt: Date, userCount: Int, userCountTruncated: Bool) { + public init(activities: [ActivityResponse], activityCount: Int, createdAt: Date, group: String, isWatched: Bool? = nil, score: Float, updatedAt: Date, userCount: Int, userCountTruncated: Bool) { self.activities = activities self.activityCount = activityCount self.createdAt = createdAt self.group = group + self.isWatched = isWatched self.score = score self.updatedAt = updatedAt self.userCount = userCount @@ -31,6 +33,7 @@ public final class AggregatedActivityResponse: @unchecked Sendable, Codable, JSO case activityCount = "activity_count" case createdAt = "created_at" case group + case isWatched = "is_watched" case score case updatedAt = "updated_at" case userCount = "user_count" @@ -39,13 +42,14 @@ public final class AggregatedActivityResponse: @unchecked Sendable, Codable, JSO public static func == (lhs: AggregatedActivityResponse, rhs: AggregatedActivityResponse) -> Bool { lhs.activities == rhs.activities && - lhs.activityCount == rhs.activityCount && - lhs.createdAt == rhs.createdAt && - lhs.group == rhs.group && - lhs.score == rhs.score && - lhs.updatedAt == rhs.updatedAt && - lhs.userCount == rhs.userCount && - lhs.userCountTruncated == rhs.userCountTruncated + lhs.activityCount == rhs.activityCount && + lhs.createdAt == rhs.createdAt && + lhs.group == rhs.group && + lhs.isWatched == rhs.isWatched && + lhs.score == rhs.score && + lhs.updatedAt == rhs.updatedAt && + lhs.userCount == rhs.userCount && + lhs.userCountTruncated == rhs.userCountTruncated } public func hash(into hasher: inout Hasher) { @@ -53,6 +57,7 @@ public final class AggregatedActivityResponse: @unchecked Sendable, Codable, JSO hasher.combine(activityCount) hasher.combine(createdAt) hasher.combine(group) + hasher.combine(isWatched) hasher.combine(score) hasher.combine(updatedAt) hasher.combine(userCount) diff --git a/Sources/StreamFeeds/generated/feeds/models/AppResponseFields.swift b/Sources/StreamFeeds/generated/feeds/models/AppResponseFields.swift index 1f7ecc3..aade4c8 100644 --- a/Sources/StreamFeeds/generated/feeds/models/AppResponseFields.swift +++ b/Sources/StreamFeeds/generated/feeds/models/AppResponseFields.swift @@ -9,38 +9,48 @@ public final class AppResponseFields: @unchecked Sendable, Codable, JSONEncodabl public var asyncUrlEnrichEnabled: Bool public var autoTranslationEnabled: Bool public var fileUploadConfig: FileUploadConfig + public var id: Int public var imageUploadConfig: FileUploadConfig public var name: String + public var placement: String - public init(asyncUrlEnrichEnabled: Bool, autoTranslationEnabled: Bool, fileUploadConfig: FileUploadConfig, imageUploadConfig: FileUploadConfig, name: String) { + public init(asyncUrlEnrichEnabled: Bool, autoTranslationEnabled: Bool, fileUploadConfig: FileUploadConfig, id: Int, imageUploadConfig: FileUploadConfig, name: String, placement: String) { self.asyncUrlEnrichEnabled = asyncUrlEnrichEnabled self.autoTranslationEnabled = autoTranslationEnabled self.fileUploadConfig = fileUploadConfig + self.id = id self.imageUploadConfig = imageUploadConfig self.name = name + self.placement = placement } public enum CodingKeys: String, CodingKey, CaseIterable { case asyncUrlEnrichEnabled = "async_url_enrich_enabled" case autoTranslationEnabled = "auto_translation_enabled" case fileUploadConfig = "file_upload_config" + case id case imageUploadConfig = "image_upload_config" case name + case placement } public static func == (lhs: AppResponseFields, rhs: AppResponseFields) -> Bool { lhs.asyncUrlEnrichEnabled == rhs.asyncUrlEnrichEnabled && - lhs.autoTranslationEnabled == rhs.autoTranslationEnabled && - lhs.fileUploadConfig == rhs.fileUploadConfig && - lhs.imageUploadConfig == rhs.imageUploadConfig && - lhs.name == rhs.name + lhs.autoTranslationEnabled == rhs.autoTranslationEnabled && + lhs.fileUploadConfig == rhs.fileUploadConfig && + lhs.id == rhs.id && + lhs.imageUploadConfig == rhs.imageUploadConfig && + lhs.name == rhs.name && + lhs.placement == rhs.placement } public func hash(into hasher: inout Hasher) { hasher.combine(asyncUrlEnrichEnabled) hasher.combine(autoTranslationEnabled) hasher.combine(fileUploadConfig) + hasher.combine(id) hasher.combine(imageUploadConfig) hasher.combine(name) + hasher.combine(placement) } } diff --git a/Sources/StreamFeeds/generated/feeds/models/BlockActionRequest.swift b/Sources/StreamFeeds/generated/feeds/models/BlockActionRequest.swift new file mode 100644 index 0000000..aaf5d28 --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/BlockActionRequest.swift @@ -0,0 +1,26 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +public final class BlockActionRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { + public var reason: String? + + public init(reason: String? = nil) { + self.reason = reason + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case reason + } + + public static func == (lhs: BlockActionRequest, rhs: BlockActionRequest) -> Bool { + lhs.reason == rhs.reason + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(reason) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/EntityCreator.swift b/Sources/StreamFeeds/generated/feeds/models/EntityCreator.swift deleted file mode 100644 index 2850963..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/EntityCreator.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class EntityCreator: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var avgResponseTime: Int? - public var banCount: Int - public var banExpires: Date? - public var banned: Bool - public var createdAt: Date? - public var custom: [String: RawJSON] - public var deactivatedAt: Date? - public var deletedAt: Date? - public var deletedContentCount: Int - public var id: String - public var invisible: Bool? - public var language: String? - public var lastActive: Date? - public var lastEngagedAt: Date? - public var online: Bool - public var privacySettings: PrivacySettings? - public var revokeTokensIssuedBefore: Date? - public var role: String - public var teams: [String]? - public var teamsRole: [String: String] - public var updatedAt: Date? - - public init(avgResponseTime: Int? = nil, banCount: Int, banExpires: Date? = nil, banned: Bool, createdAt: Date? = nil, custom: [String: RawJSON], deactivatedAt: Date? = nil, deletedAt: Date? = nil, deletedContentCount: Int, id: String, invisible: Bool? = nil, language: String? = nil, lastActive: Date? = nil, lastEngagedAt: Date? = nil, online: Bool, privacySettings: PrivacySettings? = nil, revokeTokensIssuedBefore: Date? = nil, role: String, teams: [String]? = nil, teamsRole: [String: String], updatedAt: Date? = nil) { - self.avgResponseTime = avgResponseTime - self.banCount = banCount - self.banExpires = banExpires - self.banned = banned - self.createdAt = createdAt - self.custom = custom - self.deactivatedAt = deactivatedAt - self.deletedAt = deletedAt - self.deletedContentCount = deletedContentCount - self.id = id - self.invisible = invisible - self.language = language - self.lastActive = lastActive - self.lastEngagedAt = lastEngagedAt - self.online = online - self.privacySettings = privacySettings - self.revokeTokensIssuedBefore = revokeTokensIssuedBefore - self.role = role - self.teams = teams - self.teamsRole = teamsRole - self.updatedAt = updatedAt - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case avgResponseTime = "avg_response_time" - case banCount = "ban_count" - case banExpires = "ban_expires" - case banned - case createdAt = "created_at" - case custom - case deactivatedAt = "deactivated_at" - case deletedAt = "deleted_at" - case deletedContentCount = "deleted_content_count" - case id - case invisible - case language - case lastActive = "last_active" - case lastEngagedAt = "last_engaged_at" - case online - case privacySettings = "privacy_settings" - case revokeTokensIssuedBefore = "revoke_tokens_issued_before" - case role - case teams - case teamsRole = "teams_role" - case updatedAt = "updated_at" - } - - public static func == (lhs: EntityCreator, rhs: EntityCreator) -> Bool { - lhs.avgResponseTime == rhs.avgResponseTime && - lhs.banCount == rhs.banCount && - lhs.banExpires == rhs.banExpires && - lhs.banned == rhs.banned && - lhs.createdAt == rhs.createdAt && - lhs.custom == rhs.custom && - lhs.deactivatedAt == rhs.deactivatedAt && - lhs.deletedAt == rhs.deletedAt && - lhs.deletedContentCount == rhs.deletedContentCount && - lhs.id == rhs.id && - lhs.invisible == rhs.invisible && - lhs.language == rhs.language && - lhs.lastActive == rhs.lastActive && - lhs.lastEngagedAt == rhs.lastEngagedAt && - lhs.online == rhs.online && - lhs.privacySettings == rhs.privacySettings && - lhs.revokeTokensIssuedBefore == rhs.revokeTokensIssuedBefore && - lhs.role == rhs.role && - lhs.teams == rhs.teams && - lhs.teamsRole == rhs.teamsRole && - lhs.updatedAt == rhs.updatedAt - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(avgResponseTime) - hasher.combine(banCount) - hasher.combine(banExpires) - hasher.combine(banned) - hasher.combine(createdAt) - hasher.combine(custom) - hasher.combine(deactivatedAt) - hasher.combine(deletedAt) - hasher.combine(deletedContentCount) - hasher.combine(id) - hasher.combine(invisible) - hasher.combine(language) - hasher.combine(lastActive) - hasher.combine(lastEngagedAt) - hasher.combine(online) - hasher.combine(privacySettings) - hasher.combine(revokeTokensIssuedBefore) - hasher.combine(role) - hasher.combine(teams) - hasher.combine(teamsRole) - hasher.combine(updatedAt) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/EntityCreatorResponse.swift b/Sources/StreamFeeds/generated/feeds/models/EntityCreatorResponse.swift index 0ecd27f..827e78d 100644 --- a/Sources/StreamFeeds/generated/feeds/models/EntityCreatorResponse.swift +++ b/Sources/StreamFeeds/generated/feeds/models/EntityCreatorResponse.swift @@ -1,7 +1,3 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - import Foundation import StreamCore @@ -52,52 +48,52 @@ public final class EntityCreatorResponse: @unchecked Sendable, Codable, JSONEnco self.updatedAt = updatedAt } - public enum CodingKeys: String, CodingKey, CaseIterable { - case avgResponseTime = "avg_response_time" - case banCount = "ban_count" - case banned - case blockedUserIds = "blocked_user_ids" - case createdAt = "created_at" - case custom - case deactivatedAt = "deactivated_at" - case deletedAt = "deleted_at" - case deletedContentCount = "deleted_content_count" - case flaggedCount = "flagged_count" - case id - case image - case language - case lastActive = "last_active" - case name - case online - case revokeTokensIssuedBefore = "revoke_tokens_issued_before" - case role - case teams - case teamsRole = "teams_role" - case updatedAt = "updated_at" - } +public enum CodingKeys: String, CodingKey, CaseIterable { + case avgResponseTime = "avg_response_time" + case banCount = "ban_count" + case banned + case blockedUserIds = "blocked_user_ids" + case createdAt = "created_at" + case custom + case deactivatedAt = "deactivated_at" + case deletedAt = "deleted_at" + case deletedContentCount = "deleted_content_count" + case flaggedCount = "flagged_count" + case id + case image + case language + case lastActive = "last_active" + case name + case online + case revokeTokensIssuedBefore = "revoke_tokens_issued_before" + case role + case teams + case teamsRole = "teams_role" + case updatedAt = "updated_at" +} public static func == (lhs: EntityCreatorResponse, rhs: EntityCreatorResponse) -> Bool { lhs.avgResponseTime == rhs.avgResponseTime && - lhs.banCount == rhs.banCount && - lhs.banned == rhs.banned && - lhs.blockedUserIds == rhs.blockedUserIds && - lhs.createdAt == rhs.createdAt && - lhs.custom == rhs.custom && - lhs.deactivatedAt == rhs.deactivatedAt && - lhs.deletedAt == rhs.deletedAt && - lhs.deletedContentCount == rhs.deletedContentCount && - lhs.flaggedCount == rhs.flaggedCount && - lhs.id == rhs.id && - lhs.image == rhs.image && - lhs.language == rhs.language && - lhs.lastActive == rhs.lastActive && - lhs.name == rhs.name && - lhs.online == rhs.online && - lhs.revokeTokensIssuedBefore == rhs.revokeTokensIssuedBefore && - lhs.role == rhs.role && - lhs.teams == rhs.teams && - lhs.teamsRole == rhs.teamsRole && - lhs.updatedAt == rhs.updatedAt + lhs.banCount == rhs.banCount && + lhs.banned == rhs.banned && + lhs.blockedUserIds == rhs.blockedUserIds && + lhs.createdAt == rhs.createdAt && + lhs.custom == rhs.custom && + lhs.deactivatedAt == rhs.deactivatedAt && + lhs.deletedAt == rhs.deletedAt && + lhs.deletedContentCount == rhs.deletedContentCount && + lhs.flaggedCount == rhs.flaggedCount && + lhs.id == rhs.id && + lhs.image == rhs.image && + lhs.language == rhs.language && + lhs.lastActive == rhs.lastActive && + lhs.name == rhs.name && + lhs.online == rhs.online && + lhs.revokeTokensIssuedBefore == rhs.revokeTokensIssuedBefore && + lhs.role == rhs.role && + lhs.teams == rhs.teams && + lhs.teamsRole == rhs.teamsRole && + lhs.updatedAt == rhs.updatedAt } public func hash(into hasher: inout Hasher) { diff --git a/Sources/StreamFeeds/generated/feeds/models/EventNotificationSettings.swift b/Sources/StreamFeeds/generated/feeds/models/EventNotificationSettings.swift deleted file mode 100644 index 039942d..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/EventNotificationSettings.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class EventNotificationSettings: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var apns: APNS - public var enabled: Bool - public var fcm: FCM - - public init(apns: APNS, enabled: Bool, fcm: FCM) { - self.apns = apns - self.enabled = enabled - self.fcm = fcm - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case apns - case enabled - case fcm - } - - public static func == (lhs: EventNotificationSettings, rhs: EventNotificationSettings) -> Bool { - lhs.apns == rhs.apns && - lhs.enabled == rhs.enabled && - lhs.fcm == rhs.fcm - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(apns) - hasher.combine(enabled) - hasher.combine(fcm) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/ExternalStorage.swift b/Sources/StreamFeeds/generated/feeds/models/ExternalStorage.swift deleted file mode 100644 index b528112..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/ExternalStorage.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class ExternalStorage: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var absAccountName: String? - public var absClientId: String? - public var absClientSecret: String? - public var absTenantId: String? - public var bucket: String? - public var gcsCredentials: String? - public var path: String? - public var s3ApiKey: String? - public var s3CustomEndpoint: String? - public var s3Region: String? - public var s3SecretKey: String? - public var storageName: String? - public var storageType: Int? - - public init(absAccountName: String? = nil, absClientId: String? = nil, absClientSecret: String? = nil, absTenantId: String? = nil, bucket: String? = nil, gcsCredentials: String? = nil, path: String? = nil, s3ApiKey: String? = nil, s3CustomEndpoint: String? = nil, s3Region: String? = nil, s3SecretKey: String? = nil, storageName: String? = nil, storageType: Int? = nil) { - self.absAccountName = absAccountName - self.absClientId = absClientId - self.absClientSecret = absClientSecret - self.absTenantId = absTenantId - self.bucket = bucket - self.gcsCredentials = gcsCredentials - self.path = path - self.s3ApiKey = s3ApiKey - self.s3CustomEndpoint = s3CustomEndpoint - self.s3Region = s3Region - self.s3SecretKey = s3SecretKey - self.storageName = storageName - self.storageType = storageType - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case absAccountName = "abs_account_name" - case absClientId = "abs_client_id" - case absClientSecret = "abs_client_secret" - case absTenantId = "abs_tenant_id" - case bucket - case gcsCredentials = "gcs_credentials" - case path - case s3ApiKey = "s3_api_key" - case s3CustomEndpoint = "s3_custom_endpoint" - case s3Region = "s3_region" - case s3SecretKey = "s3_secret_key" - case storageName = "storage_name" - case storageType = "storage_type" - } - - public static func == (lhs: ExternalStorage, rhs: ExternalStorage) -> Bool { - lhs.absAccountName == rhs.absAccountName && - lhs.absClientId == rhs.absClientId && - lhs.absClientSecret == rhs.absClientSecret && - lhs.absTenantId == rhs.absTenantId && - lhs.bucket == rhs.bucket && - lhs.gcsCredentials == rhs.gcsCredentials && - lhs.path == rhs.path && - lhs.s3ApiKey == rhs.s3ApiKey && - lhs.s3CustomEndpoint == rhs.s3CustomEndpoint && - lhs.s3Region == rhs.s3Region && - lhs.s3SecretKey == rhs.s3SecretKey && - lhs.storageName == rhs.storageName && - lhs.storageType == rhs.storageType - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(absAccountName) - hasher.combine(absClientId) - hasher.combine(absClientSecret) - hasher.combine(absTenantId) - hasher.combine(bucket) - hasher.combine(gcsCredentials) - hasher.combine(path) - hasher.combine(s3ApiKey) - hasher.combine(s3CustomEndpoint) - hasher.combine(s3Region) - hasher.combine(s3SecretKey) - hasher.combine(storageName) - hasher.combine(storageType) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/FCM.swift b/Sources/StreamFeeds/generated/feeds/models/FCM.swift deleted file mode 100644 index 6952673..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/FCM.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class FCM: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var data: [String: RawJSON]? - - public init(data: [String: RawJSON]? = nil) { - self.data = data - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case data - } - - public static func == (lhs: FCM, rhs: FCM) -> Bool { - lhs.data == rhs.data - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(data) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/FeedOwnCapability.swift b/Sources/StreamFeeds/generated/feeds/models/FeedOwnCapability.swift index 4bd35df..69478de 100644 --- a/Sources/StreamFeeds/generated/feeds/models/FeedOwnCapability.swift +++ b/Sources/StreamFeeds/generated/feeds/models/FeedOwnCapability.swift @@ -20,13 +20,13 @@ public enum FeedOwnCapability: String, Sendable, Codable, CaseIterable { case deleteOwnActivityReaction = "delete-own-activity-reaction" case deleteOwnComment = "delete-own-comment" case deleteOwnCommentReaction = "delete-own-comment-reaction" - case follow + case follow = "follow" case pinActivity = "pin-activity" case queryFeedMembers = "query-feed-members" case queryFollows = "query-follows" case readActivities = "read-activities" case readFeed = "read-feed" - case unfollow + case unfollow = "unfollow" case updateAnyActivity = "update-any-activity" case updateAnyComment = "update-any-comment" case updateFeed = "update-feed" diff --git a/Sources/StreamFeeds/generated/feeds/models/FeedSuggestionResponse.swift b/Sources/StreamFeeds/generated/feeds/models/FeedSuggestionResponse.swift new file mode 100644 index 0000000..452991a --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/FeedSuggestionResponse.swift @@ -0,0 +1,127 @@ +import Foundation +import StreamCore + +public final class FeedSuggestionResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { + public var algorithmScores: [String: Float]? + public var createdAt: Date + public var createdBy: UserResponse + public var custom: [String: RawJSON]? + public var deletedAt: Date? + public var description: String + public var feed: String + public var filterTags: [String]? + public var followerCount: Int + public var followingCount: Int + public var groupId: String + public var id: String + public var memberCount: Int + public var name: String + public var ownCapabilities: [FeedOwnCapability]? + public var ownFollows: [FollowResponse]? + public var ownMembership: FeedMemberResponse? + public var pinCount: Int + public var reason: String? + public var recommendationScore: Float? + public var updatedAt: Date + public var visibility: String? + + public init(algorithmScores: [String: Float]? = nil, createdAt: Date, createdBy: UserResponse, custom: [String: RawJSON]? = nil, deletedAt: Date? = nil, description: String, feed: String, filterTags: [String]? = nil, followerCount: Int, followingCount: Int, groupId: String, id: String, memberCount: Int, name: String, ownCapabilities: [FeedOwnCapability]? = nil, ownFollows: [FollowResponse]? = nil, ownMembership: FeedMemberResponse? = nil, pinCount: Int, reason: String? = nil, recommendationScore: Float? = nil, updatedAt: Date, visibility: String? = nil) { + self.algorithmScores = algorithmScores + self.createdAt = createdAt + self.createdBy = createdBy + self.custom = custom + self.deletedAt = deletedAt + self.description = description + self.feed = feed + self.filterTags = filterTags + self.followerCount = followerCount + self.followingCount = followingCount + self.groupId = groupId + self.id = id + self.memberCount = memberCount + self.name = name + self.ownCapabilities = ownCapabilities + self.ownFollows = ownFollows + self.ownMembership = ownMembership + self.pinCount = pinCount + self.reason = reason + self.recommendationScore = recommendationScore + self.updatedAt = updatedAt + self.visibility = visibility + } + +public enum CodingKeys: String, CodingKey, CaseIterable { + case algorithmScores = "algorithm_scores" + case createdAt = "created_at" + case createdBy = "created_by" + case custom + case deletedAt = "deleted_at" + case description + case feed + case filterTags = "filter_tags" + case followerCount = "follower_count" + case followingCount = "following_count" + case groupId = "group_id" + case id + case memberCount = "member_count" + case name + case ownCapabilities = "own_capabilities" + case ownFollows = "own_follows" + case ownMembership = "own_membership" + case pinCount = "pin_count" + case reason + case recommendationScore = "recommendation_score" + case updatedAt = "updated_at" + case visibility +} + + public static func == (lhs: FeedSuggestionResponse, rhs: FeedSuggestionResponse) -> Bool { + lhs.algorithmScores == rhs.algorithmScores && + lhs.createdAt == rhs.createdAt && + lhs.createdBy == rhs.createdBy && + lhs.custom == rhs.custom && + lhs.deletedAt == rhs.deletedAt && + lhs.description == rhs.description && + lhs.feed == rhs.feed && + lhs.filterTags == rhs.filterTags && + lhs.followerCount == rhs.followerCount && + lhs.followingCount == rhs.followingCount && + lhs.groupId == rhs.groupId && + lhs.id == rhs.id && + lhs.memberCount == rhs.memberCount && + lhs.name == rhs.name && + lhs.ownCapabilities == rhs.ownCapabilities && + lhs.ownFollows == rhs.ownFollows && + lhs.ownMembership == rhs.ownMembership && + lhs.pinCount == rhs.pinCount && + lhs.reason == rhs.reason && + lhs.recommendationScore == rhs.recommendationScore && + lhs.updatedAt == rhs.updatedAt && + lhs.visibility == rhs.visibility + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(algorithmScores) + hasher.combine(createdAt) + hasher.combine(createdBy) + hasher.combine(custom) + hasher.combine(deletedAt) + hasher.combine(description) + hasher.combine(feed) + hasher.combine(filterTags) + hasher.combine(followerCount) + hasher.combine(followingCount) + hasher.combine(groupId) + hasher.combine(id) + hasher.combine(memberCount) + hasher.combine(name) + hasher.combine(ownCapabilities) + hasher.combine(ownFollows) + hasher.combine(ownMembership) + hasher.combine(pinCount) + hasher.combine(reason) + hasher.combine(recommendationScore) + hasher.combine(updatedAt) + hasher.combine(visibility) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/FeedsEvent.swift b/Sources/StreamFeeds/generated/feeds/models/FeedsEvent.swift index 74c77f2..e2c46c6 100644 --- a/Sources/StreamFeeds/generated/feeds/models/FeedsEvent.swift +++ b/Sources/StreamFeeds/generated/feeds/models/FeedsEvent.swift @@ -13,6 +13,7 @@ public enum FeedsEvent: Codable, Hashable { case typeAppUpdatedEvent(AppUpdatedEvent) case typeActivityAddedEvent(ActivityAddedEvent) case typeActivityDeletedEvent(ActivityDeletedEvent) + case typeActivityFeedbackEvent(ActivityFeedbackEvent) case typeActivityMarkEvent(ActivityMarkEvent) case typeActivityPinnedEvent(ActivityPinnedEvent) case typeActivityReactionAddedEvent(ActivityReactionAddedEvent) @@ -50,15 +51,15 @@ public enum FeedsEvent: Codable, Hashable { case typePollVoteCastedFeedEvent(PollVoteCastedFeedEvent) case typePollVoteChangedFeedEvent(PollVoteChangedFeedEvent) case typePollVoteRemovedFeedEvent(PollVoteRemovedFeedEvent) + case typeStoriesFeedUpdatedEvent(StoriesFeedUpdatedEvent) case typeHealthCheckEvent(HealthCheckEvent) case typeModerationCustomActionEvent(ModerationCustomActionEvent) - case typeModerationFlaggedEvent(ModerationFlaggedEvent) case typeModerationMarkReviewedEvent(ModerationMarkReviewedEvent) + case typeUserUpdatedEvent(UserUpdatedEvent) case typeUserBannedEvent(UserBannedEvent) case typeUserDeactivatedEvent(UserDeactivatedEvent) case typeUserMutedEvent(UserMutedEvent) case typeUserReactivatedEvent(UserReactivatedEvent) - case typeUserUpdatedEvent(UserUpdatedEvent) case typeConnectedEvent(ConnectedEvent) case typeConnectionErrorEvent(ConnectionErrorEvent) @@ -148,8 +149,6 @@ public enum FeedsEvent: Codable, Hashable { value.type case .typeModerationCustomActionEvent(let value): value.type - case .typeModerationFlaggedEvent(let value): - value.type case .typeModerationMarkReviewedEvent(let value): value.type case .typeUserBannedEvent(let value): @@ -166,6 +165,10 @@ public enum FeedsEvent: Codable, Hashable { value.type case let .typeConnectionErrorEvent(value): value.type + case let .typeActivityFeedbackEvent(value): + value.type + case let .typeStoriesFeedUpdatedEvent(value): + value.type } } @@ -255,8 +258,6 @@ public enum FeedsEvent: Codable, Hashable { value case .typeModerationCustomActionEvent(let value): value - case .typeModerationFlaggedEvent(let value): - value case .typeModerationMarkReviewedEvent(let value): value case .typeUserBannedEvent(let value): @@ -273,6 +274,10 @@ public enum FeedsEvent: Codable, Hashable { value case .typeConnectionErrorEvent(let value): value + case .typeActivityFeedbackEvent(let value): + value + case .typeStoriesFeedUpdatedEvent(let value): + value } } @@ -363,8 +368,6 @@ public enum FeedsEvent: Codable, Hashable { try container.encode(value) case .typeModerationCustomActionEvent(let value): try container.encode(value) - case .typeModerationFlaggedEvent(let value): - try container.encode(value) case .typeModerationMarkReviewedEvent(let value): try container.encode(value) case .typeUserBannedEvent(let value): @@ -381,6 +384,10 @@ public enum FeedsEvent: Codable, Hashable { try container.encode(value) case .typeConnectionErrorEvent(let value): try container.encode(value) + case .typeActivityFeedbackEvent(let value): + try container.encode(value) + case .typeStoriesFeedUpdatedEvent(let value): + try container.encode(value) } } @@ -396,6 +403,9 @@ public enum FeedsEvent: Codable, Hashable { } else if dto.type == "feeds.activity.deleted" { let value = try container.decode(ActivityDeletedEvent.self) self = .typeActivityDeletedEvent(value) + } else if dto.type == "feeds.activity.feedback" { + let value = try container.decode(ActivityFeedbackEvent.self) + self = .typeActivityFeedbackEvent(value) } else if dto.type == "feeds.activity.marked" { let value = try container.decode(ActivityMarkEvent.self) self = .typeActivityMarkEvent(value) @@ -507,15 +517,15 @@ public enum FeedsEvent: Codable, Hashable { } else if dto.type == "feeds.poll.vote_removed" { let value = try container.decode(PollVoteRemovedFeedEvent.self) self = .typePollVoteRemovedFeedEvent(value) + } else if dto.type == "feeds.stories_feed.updated" { + let value = try container.decode(StoriesFeedUpdatedEvent.self) + self = .typeStoriesFeedUpdatedEvent(value) } else if dto.type == "health.check" { let value = try container.decode(HealthCheckEvent.self) self = .typeHealthCheckEvent(value) } else if dto.type == "moderation.custom_action" { let value = try container.decode(ModerationCustomActionEvent.self) self = .typeModerationCustomActionEvent(value) - } else if dto.type == "moderation.flagged" { - let value = try container.decode(ModerationFlaggedEvent.self) - self = .typeModerationFlaggedEvent(value) } else if dto.type == "moderation.mark_reviewed" { let value = try container.decode(ModerationMarkReviewedEvent.self) self = .typeModerationMarkReviewedEvent(value) diff --git a/Sources/StreamFeeds/generated/feeds/models/Flag.swift b/Sources/StreamFeeds/generated/feeds/models/Flag.swift deleted file mode 100644 index 4115bfa..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/Flag.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class Flag: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var createdAt: Date - public var custom: [String: RawJSON]? - public var entityCreatorId: String? - public var entityId: String - public var entityType: String - public var isStreamedContent: Bool? - public var labels: [String]? - public var moderationPayload: ModerationPayload? - public var moderationPayloadHash: String? - public var reason: String? - public var result: [[String: RawJSON]] - public var reviewQueueItem: ReviewQueueItem? - public var reviewQueueItemId: String? - public var type: String? - public var updatedAt: Date - public var user: User? - - public init(createdAt: Date, custom: [String: RawJSON]? = nil, entityCreatorId: String? = nil, entityId: String, entityType: String, isStreamedContent: Bool? = nil, labels: [String]? = nil, moderationPayload: ModerationPayload? = nil, moderationPayloadHash: String? = nil, reason: String? = nil, result: [[String: RawJSON]], reviewQueueItem: ReviewQueueItem? = nil, reviewQueueItemId: String? = nil, updatedAt: Date, user: User? = nil) { - self.createdAt = createdAt - self.custom = custom - self.entityCreatorId = entityCreatorId - self.entityId = entityId - self.entityType = entityType - self.isStreamedContent = isStreamedContent - self.labels = labels - self.moderationPayload = moderationPayload - self.moderationPayloadHash = moderationPayloadHash - self.reason = reason - self.result = result - self.reviewQueueItem = reviewQueueItem - self.reviewQueueItemId = reviewQueueItemId - self.updatedAt = updatedAt - self.user = user - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case createdAt = "created_at" - case custom - case entityCreatorId = "entity_creator_id" - case entityId = "entity_id" - case entityType = "entity_type" - case isStreamedContent = "is_streamed_content" - case labels - case moderationPayload = "moderation_payload" - case moderationPayloadHash = "moderation_payload_hash" - case reason - case result - case reviewQueueItem = "review_queue_item" - case reviewQueueItemId = "review_queue_item_id" - case type - case updatedAt = "updated_at" - case user - } - - public static func == (lhs: Flag, rhs: Flag) -> Bool { - lhs.createdAt == rhs.createdAt && - lhs.custom == rhs.custom && - lhs.entityCreatorId == rhs.entityCreatorId && - lhs.entityId == rhs.entityId && - lhs.entityType == rhs.entityType && - lhs.isStreamedContent == rhs.isStreamedContent && - lhs.labels == rhs.labels && - lhs.moderationPayload == rhs.moderationPayload && - lhs.moderationPayloadHash == rhs.moderationPayloadHash && - lhs.reason == rhs.reason && - lhs.result == rhs.result && - lhs.reviewQueueItem == rhs.reviewQueueItem && - lhs.reviewQueueItemId == rhs.reviewQueueItemId && - lhs.type == rhs.type && - lhs.updatedAt == rhs.updatedAt && - lhs.user == rhs.user - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(createdAt) - hasher.combine(custom) - hasher.combine(entityCreatorId) - hasher.combine(entityId) - hasher.combine(entityType) - hasher.combine(isStreamedContent) - hasher.combine(labels) - hasher.combine(moderationPayload) - hasher.combine(moderationPayloadHash) - hasher.combine(reason) - hasher.combine(result) - hasher.combine(reviewQueueItem) - hasher.combine(reviewQueueItemId) - hasher.combine(type) - hasher.combine(updatedAt) - hasher.combine(user) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/MemberLookup.swift b/Sources/StreamFeeds/generated/feeds/models/MemberLookup.swift deleted file mode 100644 index 5862249..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/MemberLookup.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class MemberLookup: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var limit: Int - - public init(limit: Int) { - self.limit = limit - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case limit = "Limit" - } - - public static func == (lhs: MemberLookup, rhs: MemberLookup) -> Bool { - lhs.limit == rhs.limit - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(limit) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/ModerationCustomActionEvent.swift b/Sources/StreamFeeds/generated/feeds/models/ModerationCustomActionEvent.swift index 830ccba..dff3e2e 100644 --- a/Sources/StreamFeeds/generated/feeds/models/ModerationCustomActionEvent.swift +++ b/Sources/StreamFeeds/generated/feeds/models/ModerationCustomActionEvent.swift @@ -5,36 +5,51 @@ import Foundation import StreamCore -public final class ModerationCustomActionEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { +public final class ModerationCustomActionEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { + public var actionId: String + public var actionOptions: [String: RawJSON]? public var createdAt: Date - public var item: ReviewQueueItem? + public var custom: [String: RawJSON] + public var receivedAt: Date? + public var reviewQueueItem: ReviewQueueItemResponse public var type: String = "moderation.custom_action" - public var user: User? - public init(createdAt: Date, item: ReviewQueueItem? = nil, user: User? = nil) { + public init(actionId: String, actionOptions: [String: RawJSON]? = nil, createdAt: Date, custom: [String: RawJSON], receivedAt: Date? = nil, reviewQueueItem: ReviewQueueItemResponse) { + self.actionId = actionId + self.actionOptions = actionOptions self.createdAt = createdAt - self.item = item - self.user = user + self.custom = custom + self.receivedAt = receivedAt + self.reviewQueueItem = reviewQueueItem } public enum CodingKeys: String, CodingKey, CaseIterable { + case actionId = "action_id" + case actionOptions = "action_options" case createdAt = "created_at" - case item + case custom + case receivedAt = "received_at" + case reviewQueueItem = "review_queue_item" case type - case user } public static func == (lhs: ModerationCustomActionEvent, rhs: ModerationCustomActionEvent) -> Bool { + lhs.actionId == rhs.actionId && + lhs.actionOptions == rhs.actionOptions && lhs.createdAt == rhs.createdAt && - lhs.item == rhs.item && - lhs.type == rhs.type && - lhs.user == rhs.user + lhs.custom == rhs.custom && + lhs.receivedAt == rhs.receivedAt && + lhs.reviewQueueItem == rhs.reviewQueueItem && + lhs.type == rhs.type } public func hash(into hasher: inout Hasher) { + hasher.combine(actionId) + hasher.combine(actionOptions) hasher.combine(createdAt) - hasher.combine(item) + hasher.combine(custom) + hasher.combine(receivedAt) + hasher.combine(reviewQueueItem) hasher.combine(type) - hasher.combine(user) } } diff --git a/Sources/StreamFeeds/generated/feeds/models/ModerationMarkReviewedEvent.swift b/Sources/StreamFeeds/generated/feeds/models/ModerationMarkReviewedEvent.swift index 57b8843..6264f34 100644 --- a/Sources/StreamFeeds/generated/feeds/models/ModerationMarkReviewedEvent.swift +++ b/Sources/StreamFeeds/generated/feeds/models/ModerationMarkReviewedEvent.swift @@ -7,11 +7,11 @@ import StreamCore public final class ModerationMarkReviewedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { public var createdAt: Date - public var item: ReviewQueueItem? + public var item: ReviewQueueItemResponse? public var type: String = "moderation.mark_reviewed" public var user: User? - public init(createdAt: Date, item: ReviewQueueItem? = nil, user: User? = nil) { + public init(createdAt: Date, item: ReviewQueueItemResponse? = nil, user: User? = nil) { self.createdAt = createdAt self.item = item self.user = user diff --git a/Sources/StreamFeeds/generated/feeds/models/OwnCapabilitiesBatchRequest.swift b/Sources/StreamFeeds/generated/feeds/models/OwnCapabilitiesBatchRequest.swift new file mode 100644 index 0000000..b818b64 --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/OwnCapabilitiesBatchRequest.swift @@ -0,0 +1,26 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +public final class OwnCapabilitiesBatchRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { + public var feeds: [String] + + public init(feeds: [String]) { + self.feeds = feeds + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case feeds + } + + public static func == (lhs: OwnCapabilitiesBatchRequest, rhs: OwnCapabilitiesBatchRequest) -> Bool { + lhs.feeds == rhs.feeds + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(feeds) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/OwnCapabilitiesBatchResponse.swift b/Sources/StreamFeeds/generated/feeds/models/OwnCapabilitiesBatchResponse.swift new file mode 100644 index 0000000..211a46b --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/OwnCapabilitiesBatchResponse.swift @@ -0,0 +1,31 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +public final class OwnCapabilitiesBatchResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { + public var capabilities: [String: [FeedOwnCapability]] + public var duration: String + + public init(capabilities: [String: [FeedOwnCapability]], duration: String) { + self.capabilities = capabilities + self.duration = duration + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case capabilities + case duration + } + + public static func == (lhs: OwnCapabilitiesBatchResponse, rhs: OwnCapabilitiesBatchResponse) -> Bool { + lhs.capabilities == rhs.capabilities && + lhs.duration == rhs.duration + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(capabilities) + hasher.combine(duration) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/ReviewQueueItem.swift b/Sources/StreamFeeds/generated/feeds/models/ReviewQueueItem.swift deleted file mode 100644 index ac9f3e2..0000000 --- a/Sources/StreamFeeds/generated/feeds/models/ReviewQueueItem.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -import StreamCore - -public final class ReviewQueueItem: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public var actions: [ActionLog] - public var activity: EnrichedActivity? - public var aiTextSeverity: String - public var assignedTo: User? - public var bans: [Ban] - public var bounceCount: Int - public var configKey: String - public var contentChanged: Bool - public var createdAt: Date - public var entityCreator: EntityCreator? - public var entityId: String - public var entityType: String - public var feedsV2Activity: EnrichedActivity? - public var flagLabels: [String] - public var flagTypes: [String] - public var flags: [Flag] - public var flagsCount: Int - public var hasImage: Bool - public var hasText: Bool - public var hasVideo: Bool - public var id: String - public var languages: [String] - public var moderationPayload: ModerationPayload? - public var moderationPayloadHash: String - public var recommendedAction: String - public var reporterIds: [String] - public var reviewedBy: String - public var severity: Int - public var status: String - public var teams: [String] - public var updatedAt: Date - - public init(actions: [ActionLog], activity: EnrichedActivity? = nil, aiTextSeverity: String, assignedTo: User? = nil, bans: [Ban], bounceCount: Int, configKey: String, contentChanged: Bool, createdAt: Date, entityCreator: EntityCreator? = nil, entityId: String, entityType: String, feedsV2Activity: EnrichedActivity? = nil, flagLabels: [String], flagTypes: [String], flags: [Flag], flagsCount: Int, hasImage: Bool, hasText: Bool, hasVideo: Bool, id: String, languages: [String], moderationPayload: ModerationPayload? = nil, moderationPayloadHash: String, recommendedAction: String, reporterIds: [String], reviewedBy: String, severity: Int, status: String, teams: [String], updatedAt: Date) { - self.actions = actions - self.activity = activity - self.aiTextSeverity = aiTextSeverity - self.assignedTo = assignedTo - self.bans = bans - self.bounceCount = bounceCount - self.configKey = configKey - self.contentChanged = contentChanged - self.createdAt = createdAt - self.entityCreator = entityCreator - self.entityId = entityId - self.entityType = entityType - self.feedsV2Activity = feedsV2Activity - self.flagLabels = flagLabels - self.flagTypes = flagTypes - self.flags = flags - self.flagsCount = flagsCount - self.hasImage = hasImage - self.hasText = hasText - self.hasVideo = hasVideo - self.id = id - self.languages = languages - self.moderationPayload = moderationPayload - self.moderationPayloadHash = moderationPayloadHash - self.recommendedAction = recommendedAction - self.reporterIds = reporterIds - self.reviewedBy = reviewedBy - self.severity = severity - self.status = status - self.teams = teams - self.updatedAt = updatedAt - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case actions - case activity - case aiTextSeverity = "ai_text_severity" - case assignedTo = "assigned_to" - case bans - case bounceCount = "bounce_count" - case configKey = "config_key" - case contentChanged = "content_changed" - case createdAt = "created_at" - case entityCreator = "entity_creator" - case entityId = "entity_id" - case entityType = "entity_type" - case feedsV2Activity = "feeds_v2_activity" - case flagLabels = "flag_labels" - case flagTypes = "flag_types" - case flags - case flagsCount = "flags_count" - case hasImage = "has_image" - case hasText = "has_text" - case hasVideo = "has_video" - case id - case languages - case moderationPayload = "moderation_payload" - case moderationPayloadHash = "moderation_payload_hash" - case recommendedAction = "recommended_action" - case reporterIds = "reporter_ids" - case reviewedBy = "reviewed_by" - case severity - case status - case teams - case updatedAt = "updated_at" - } - - public static func == (lhs: ReviewQueueItem, rhs: ReviewQueueItem) -> Bool { - lhs.actions == rhs.actions && - lhs.activity == rhs.activity && - lhs.aiTextSeverity == rhs.aiTextSeverity && - lhs.assignedTo == rhs.assignedTo && - lhs.bans == rhs.bans && - lhs.bounceCount == rhs.bounceCount && - lhs.configKey == rhs.configKey && - lhs.contentChanged == rhs.contentChanged && - lhs.createdAt == rhs.createdAt && - lhs.entityCreator == rhs.entityCreator && - lhs.entityId == rhs.entityId && - lhs.entityType == rhs.entityType && - lhs.feedsV2Activity == rhs.feedsV2Activity && - lhs.flagLabels == rhs.flagLabels && - lhs.flagTypes == rhs.flagTypes && - lhs.flags == rhs.flags && - lhs.flagsCount == rhs.flagsCount && - lhs.hasImage == rhs.hasImage && - lhs.hasText == rhs.hasText && - lhs.hasVideo == rhs.hasVideo && - lhs.id == rhs.id && - lhs.languages == rhs.languages && - lhs.moderationPayload == rhs.moderationPayload && - lhs.moderationPayloadHash == rhs.moderationPayloadHash && - lhs.recommendedAction == rhs.recommendedAction && - lhs.reporterIds == rhs.reporterIds && - lhs.reviewedBy == rhs.reviewedBy && - lhs.severity == rhs.severity && - lhs.status == rhs.status && - lhs.teams == rhs.teams && - lhs.updatedAt == rhs.updatedAt - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(actions) - hasher.combine(activity) - hasher.combine(aiTextSeverity) - hasher.combine(assignedTo) - hasher.combine(bans) - hasher.combine(bounceCount) - hasher.combine(configKey) - hasher.combine(contentChanged) - hasher.combine(createdAt) - hasher.combine(entityCreator) - hasher.combine(entityId) - hasher.combine(entityType) - hasher.combine(feedsV2Activity) - hasher.combine(flagLabels) - hasher.combine(flagTypes) - hasher.combine(flags) - hasher.combine(flagsCount) - hasher.combine(hasImage) - hasher.combine(hasText) - hasher.combine(hasVideo) - hasher.combine(id) - hasher.combine(languages) - hasher.combine(moderationPayload) - hasher.combine(moderationPayloadHash) - hasher.combine(recommendedAction) - hasher.combine(reporterIds) - hasher.combine(reviewedBy) - hasher.combine(severity) - hasher.combine(status) - hasher.combine(teams) - hasher.combine(updatedAt) - } -} diff --git a/Sources/StreamFeeds/generated/feeds/models/ShadowBlockActionRequest.swift b/Sources/StreamFeeds/generated/feeds/models/ShadowBlockActionRequest.swift new file mode 100644 index 0000000..8870738 --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/ShadowBlockActionRequest.swift @@ -0,0 +1,22 @@ +import Foundation +import StreamCore + +public final class ShadowBlockActionRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { + public var reason: String? + + public init(reason: String? = nil) { + self.reason = reason + } + +public enum CodingKeys: String, CodingKey, CaseIterable { + case reason +} + + public static func == (lhs: ShadowBlockActionRequest, rhs: ShadowBlockActionRequest) -> Bool { + lhs.reason == rhs.reason + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(reason) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/SpeechSegmentConfig.swift b/Sources/StreamFeeds/generated/feeds/models/SpeechSegmentConfig.swift new file mode 100644 index 0000000..5fb5e86 --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/SpeechSegmentConfig.swift @@ -0,0 +1,27 @@ +import Foundation +import StreamCore + +public final class SpeechSegmentConfig: @unchecked Sendable, Codable, JSONEncodable, Hashable { + public var maxSpeechCaptionMs: Int? + public var silenceDurationMs: Int? + + public init(maxSpeechCaptionMs: Int? = nil, silenceDurationMs: Int? = nil) { + self.maxSpeechCaptionMs = maxSpeechCaptionMs + self.silenceDurationMs = silenceDurationMs + } + +public enum CodingKeys: String, CodingKey, CaseIterable { + case maxSpeechCaptionMs = "max_speech_caption_ms" + case silenceDurationMs = "silence_duration_ms" +} + + public static func == (lhs: SpeechSegmentConfig, rhs: SpeechSegmentConfig) -> Bool { + lhs.maxSpeechCaptionMs == rhs.maxSpeechCaptionMs && + lhs.silenceDurationMs == rhs.silenceDurationMs + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(maxSpeechCaptionMs) + hasher.combine(silenceDurationMs) + } +} diff --git a/Sources/StreamFeeds/generated/feeds/models/StoriesConfig.swift b/Sources/StreamFeeds/generated/feeds/models/StoriesConfig.swift index 519adc3..6ad907c 100644 --- a/Sources/StreamFeeds/generated/feeds/models/StoriesConfig.swift +++ b/Sources/StreamFeeds/generated/feeds/models/StoriesConfig.swift @@ -6,42 +6,26 @@ import Foundation import StreamCore public final class StoriesConfig: @unchecked Sendable, Codable, JSONEncodable, Hashable { - public enum StoriesConfigExpirationBehaviour: String, Sendable, Codable, CaseIterable { - case hideForEveryone = "hide_for_everyone" - case visibleForAuthor = "visible_for_author" - case unknown = "_unknown" - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let decodedValue = try? container.decode(String.self), - let value = Self(rawValue: decodedValue) { - self = value - } else { - self = .unknown - } - } - } - - public var expirationBehaviour: StoriesConfigExpirationBehaviour? public var skipWatched: Bool? + public var trackWatched: Bool? - public init(expirationBehaviour: StoriesConfigExpirationBehaviour? = nil, skipWatched: Bool? = nil) { - self.expirationBehaviour = expirationBehaviour + public init(skipWatched: Bool? = nil, trackWatched: Bool? = nil) { self.skipWatched = skipWatched + self.trackWatched = trackWatched } public enum CodingKeys: String, CodingKey, CaseIterable { - case expirationBehaviour = "expiration_behaviour" case skipWatched = "skip_watched" + case trackWatched = "track_watched" } public static func == (lhs: StoriesConfig, rhs: StoriesConfig) -> Bool { - lhs.expirationBehaviour == rhs.expirationBehaviour && - lhs.skipWatched == rhs.skipWatched + lhs.skipWatched == rhs.skipWatched && + lhs.trackWatched == rhs.trackWatched } public func hash(into hasher: inout Hasher) { - hasher.combine(expirationBehaviour) hasher.combine(skipWatched) + hasher.combine(trackWatched) } } diff --git a/Sources/StreamFeeds/generated/feeds/models/StoriesFeedUpdatedEvent.swift b/Sources/StreamFeeds/generated/feeds/models/StoriesFeedUpdatedEvent.swift new file mode 100644 index 0000000..893a77f --- /dev/null +++ b/Sources/StreamFeeds/generated/feeds/models/StoriesFeedUpdatedEvent.swift @@ -0,0 +1,65 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +public final class StoriesFeedUpdatedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { + public var activities: [ActivityResponse]? + public var aggregatedActivities: [AggregatedActivityResponse]? + public var createdAt: Date + public var custom: [String: RawJSON] + public var feedVisibility: String? + public var fid: String + public var receivedAt: Date? + public var type: String = "feeds.stories_feed.updated" + public var user: UserResponseCommonFields? + + public init(activities: [ActivityResponse]? = nil, aggregatedActivities: [AggregatedActivityResponse]? = nil, createdAt: Date, custom: [String: RawJSON], feedVisibility: String? = nil, fid: String, receivedAt: Date? = nil, user: UserResponseCommonFields? = nil) { + self.activities = activities + self.aggregatedActivities = aggregatedActivities + self.createdAt = createdAt + self.custom = custom + self.feedVisibility = feedVisibility + self.fid = fid + self.receivedAt = receivedAt + self.user = user + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case activities + case aggregatedActivities = "aggregated_activities" + case createdAt = "created_at" + case custom + case feedVisibility = "feed_visibility" + case fid + case receivedAt = "received_at" + case type + case user + } + + public static func == (lhs: StoriesFeedUpdatedEvent, rhs: StoriesFeedUpdatedEvent) -> Bool { + lhs.activities == rhs.activities && + lhs.aggregatedActivities == rhs.aggregatedActivities && + lhs.createdAt == rhs.createdAt && + lhs.custom == rhs.custom && + lhs.feedVisibility == rhs.feedVisibility && + lhs.fid == rhs.fid && + lhs.receivedAt == rhs.receivedAt && + lhs.type == rhs.type && + lhs.user == rhs.user + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(activities) + hasher.combine(aggregatedActivities) + hasher.combine(createdAt) + hasher.combine(custom) + hasher.combine(feedVisibility) + hasher.combine(fid) + hasher.combine(receivedAt) + hasher.combine(type) + hasher.combine(user) + } +} diff --git a/StreamFeeds.xcodeproj/project.pbxproj b/StreamFeeds.xcodeproj/project.pbxproj index 74de1f4..35e27b5 100644 --- a/StreamFeeds.xcodeproj/project.pbxproj +++ b/StreamFeeds.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 4F0767B52EA0D0BB00E5FD18 /* StreamAttachments in Frameworks */ = {isa = PBXBuildFile; productRef = 4F0767B42EA0D0BB00E5FD18 /* StreamAttachments */; }; 82C6B2B02E1D177D00CB3B23 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 82C6B2AF2E1D177D00CB3B23 /* PrivacyInfo.xcprivacy */; }; 845494EB2DBA2E7C00211413 /* StreamFeeds.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 845494E22DBA2E7C00211413 /* StreamFeeds.framework */; }; 84AA8E642DCA45880030DD7D /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = 84AA8E632DCA45880030DD7D /* StreamCore */; }; @@ -67,9 +68,6 @@ membershipExceptions = ( StreamFeeds/Info.plist, ); - publicHeaders = ( - StreamFeeds/StreamFeeds.h, - ); target = 845494E12DBA2E7C00211413 /* StreamFeeds */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -105,6 +103,7 @@ files = ( 84AA8E642DCA45880030DD7D /* StreamCore in Frameworks */, 84B9B53B2E4A9F0300923DD8 /* StreamCore in Frameworks */, + 4F0767B52EA0D0BB00E5FD18 /* StreamAttachments in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -190,6 +189,7 @@ packageProductDependencies = ( 84AA8E632DCA45880030DD7D /* StreamCore */, 84B9B53A2E4A9F0300923DD8 /* StreamCore */, + 4F0767B42EA0D0BB00E5FD18 /* StreamAttachments */, ); productName = StreamFeeds; productReference = 845494E22DBA2E7C00211413 /* StreamFeeds.framework */; @@ -251,7 +251,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2610; TargetAttributes = { 845494E12DBA2E7C00211413 = { CreatedOnToolsVersion = 16.2; @@ -411,6 +411,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; @@ -426,7 +427,6 @@ CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -436,6 +436,8 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Stream.io Inc. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_GENERATE_MAP_FILE = YES; + LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -507,7 +509,6 @@ CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -517,6 +518,8 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Stream.io Inc. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_GENERATE_MAP_FILE = YES; + LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -542,7 +545,6 @@ CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -552,6 +554,8 @@ INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Stream.io Inc. All rights reserved."; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_GENERATE_MAP_FILE = YES; + LD_MAP_FILE_PATH = "linkmaps/$(PRODUCT_NAME)-$(CURRENT_ARCH)-LinkMap.txt"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -635,6 +639,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; @@ -702,6 +707,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; @@ -858,12 +864,17 @@ repositoryURL = "https://github.com/GetStream/stream-core-swift.git"; requirement = { kind = exactVersion; - version = 0.1.0; + version = 0.6.1; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 4F0767B42EA0D0BB00E5FD18 /* StreamAttachments */ = { + isa = XCSwiftPackageProductDependency; + package = 84B9B5392E4A9F0300923DD8 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamAttachments; + }; 84AA8E632DCA45880030DD7D /* StreamCore */ = { isa = XCSwiftPackageProductDependency; productName = StreamCore; diff --git a/StreamFeeds.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme b/StreamFeeds.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme index a9a2e4b..eac8b0b 100644 --- a/StreamFeeds.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme +++ b/StreamFeeds.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme @@ -1,6 +1,6 @@ ] = [ + feedId: [.readFeed, .addActivity] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let feedId = FeedId(group: "user", id: "jane") + let initialCapabilities: Set = [.readFeed, .addActivity] + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + GetActivityResponse.dummy( + activity: .dummy( + currentFeed: .dummy(feed: feedId.rawValue, ownCapabilities: Array(initialCapabilities)), + id: "activity-123" + ) + ), + GetCommentsResponse.dummy(comments: []) + ] + ) + ) + let activity = client.activity(for: "activity-123", in: feedId) + try await activity.get() + + await #expect(activity.state.activity?.currentFeed?.ownCapabilities == initialCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await #expect(activity.state.activity?.currentFeed?.ownCapabilities == initialCapabilities) + + // Send matching event with updated capabilities + let newCapabilities: Set = [.readFeed, .addActivity, .deleteOwnActivity] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feedId: newCapabilities]) + ) + await #expect(activity.state.activity?.currentFeed?.ownCapabilities == newCapabilities) + } + // MARK: - private func defaultClientWithActivityAndCommentsResponses( @@ -764,37 +904,42 @@ struct Activity_Tests { ) ] } - return FeedsClient.mock( - apiTransport: .withPayloads( + apiTransport: .withMatchedResponses( [ - GetActivityResponse.dummy( - activity: .dummy( - id: "activity-123", - latestReactions: [.dummy(type: "like")], - ownReactions: [.dummy(type: "like", user: .dummy(id: "current-user-id"))], - poll: .dummy( - enforceUniqueVote: uniqueVotes, - id: "poll-123", - name: "Test Poll", - options: [ - .dummy(id: "option-1", text: "Option 1"), - .dummy(id: "option-2", text: "Option 2") - ], - ownVotes: ownVotes, - voteCount: ownVotes.count, - voteCountsByOption: ["option-1": ownVotes.count, "option-2": 0] - ), - reactionCount: 1, - reactionGroups: ["like": .dummy(count: 1)], - text: "Test activity content" + .init( + matching: .pathPrefix("/api/v2/feeds/activities/"), + payload: GetActivityResponse.dummy( + activity: .dummy( + id: "activity-123", + latestReactions: [.dummy(type: "like")], + ownReactions: [.dummy(type: "like", user: .dummy(id: "current-user-id"))], + poll: .dummy( + enforceUniqueVote: uniqueVotes, + id: "poll-123", + name: "Test Poll", + options: [ + .dummy(id: "option-1", text: "Option 1"), + .dummy(id: "option-2", text: "Option 2") + ], + ownVotes: ownVotes, + voteCount: ownVotes.count, + voteCountsByOption: ["option-1": ownVotes.count, "option-2": 0] + ), + reactionCount: 1, + reactionGroups: ["like": .dummy(count: 1)], + text: "Test activity content" + ) ) ), - GetCommentsResponse.dummy(comments: [ - .dummy(id: "comment-1", objectId: "activity-123", text: "First comment"), - .dummy(id: "comment-2", objectId: "activity-123", text: "Second comment") - ]) - ] + additionalPayloads + .init( + matching: .pathPrefix("/api/v2/feeds/comments"), + payload: GetCommentsResponse.dummy(comments: [ + .dummy(id: "comment-1", objectId: "activity-123", text: "First comment"), + .dummy(id: "comment-2", objectId: "activity-123", text: "Second comment") + ]) + ) + ] + additionalPayloads.map { .init(matching: .any, result: .success($0)) } ) ) } diff --git a/Tests/StreamFeedsTests/StateLayer/BookmarkFolderListState_Tests.swift b/Tests/StreamFeedsTests/StateLayer/BookmarkFolderListState_Tests.swift new file mode 100644 index 0000000..64b59ea --- /dev/null +++ b/Tests/StreamFeedsTests/StateLayer/BookmarkFolderListState_Tests.swift @@ -0,0 +1,118 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamFeeds +import Testing + +struct BookmarkFolderListState_Tests { + // MARK: - Actions + + @Test func getUpdatesState() async throws { + let client = defaultClientWithBookmarkFolderResponses() + let bookmarkFolderList = client.bookmarkFolderList(for: BookmarkFoldersQuery()) + let folders = try await bookmarkFolderList.get() + let stateFolders = await bookmarkFolderList.state.folders + #expect(folders.count == 2) + #expect(stateFolders.count == 2) + #expect(stateFolders.map(\.id) == ["folder-1", "folder-2"]) + #expect(folders.map(\.id) == stateFolders.map(\.id)) + await #expect(bookmarkFolderList.state.canLoadMore == true) + await #expect(bookmarkFolderList.state.pagination?.next == "next-cursor") + } + + @Test func queryMoreBookmarkFoldersUpdatesState() async throws { + let client = defaultClientWithBookmarkFolderResponses([ + QueryBookmarkFoldersResponse.dummy( + bookmarkFolders: [ + .dummy(id: "folder-3", name: "Test Folder 3"), + .dummy(id: "folder-4", name: "Test Folder 4") + ], + next: "next-cursor-2" + ) + ]) + let bookmarkFolderList = client.bookmarkFolderList(for: BookmarkFoldersQuery()) + + // Initial load + _ = try await bookmarkFolderList.get() + let initialState = await bookmarkFolderList.state.folders + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["folder-1", "folder-2"]) + + // Load more + let moreFolders = try await bookmarkFolderList.queryMoreBookmarkFolders() + let updatedState = await bookmarkFolderList.state.folders + #expect(moreFolders.count == 2) + #expect(moreFolders.map(\.id) == ["folder-3", "folder-4"]) + #expect(updatedState.count == 4) + await #expect(bookmarkFolderList.state.canLoadMore == true) + await #expect(bookmarkFolderList.state.pagination?.next == "next-cursor-2") + } + + // MARK: - WebSocket Events + + @Test func bookmarkFolderUpdatedEventUpdatesState() async throws { + let client = defaultClientWithBookmarkFolderResponses() + let bookmarkFolderList = client.bookmarkFolderList(for: BookmarkFoldersQuery()) + + // Initial load + _ = try await bookmarkFolderList.get() + let initialState = await bookmarkFolderList.state.folders + #expect(initialState.count == 2) + #expect(initialState.first { $0.id == "folder-1" }?.name == "Test Folder 1") + + // Send bookmark folder updated event + let updatedFolder = BookmarkFolderResponse.dummy(id: "folder-1", name: "Updated Folder Name").toModel() + await client.stateLayerEventPublisher.sendEvent(.bookmarkFolderUpdated(updatedFolder)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await bookmarkFolderList.state.folders + #expect(updatedState.count == 2) + #expect(updatedState.first { $0.id == "folder-1" }?.name == "Updated Folder Name") + } + + @Test func bookmarkFolderDeletedEventUpdatesState() async throws { + let client = defaultClientWithBookmarkFolderResponses() + let bookmarkFolderList = client.bookmarkFolderList(for: BookmarkFoldersQuery()) + + // Initial load + _ = try await bookmarkFolderList.get() + let initialState = await bookmarkFolderList.state.folders + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["folder-1", "folder-2"]) + + // Send bookmark folder deleted event + let deletedFolder = BookmarkFolderResponse.dummy(id: "folder-1", name: "Test Folder 1").toModel() + await client.stateLayerEventPublisher.sendEvent(.bookmarkFolderDeleted(deletedFolder)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await bookmarkFolderList.state.folders + #expect(updatedState.count == 1) + #expect(updatedState.map(\.id) == ["folder-2"]) + } + + // MARK: - Helper Methods + + private func defaultClientWithBookmarkFolderResponses( + _ additionalPayloads: [any Encodable] = [] + ) -> FeedsClient { + FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarkFoldersResponse.dummy( + bookmarkFolders: [ + .dummy(id: "folder-1", name: "Test Folder 1"), + .dummy(id: "folder-2", name: "Test Folder 2") + ], + next: "next-cursor" + ) + ] + additionalPayloads + ) + ) + } +} diff --git a/Tests/StreamFeedsTests/StateLayer/BookmarkFolderList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/BookmarkFolderList_Tests.swift index 2e9a557..9141329 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/BookmarkListState_Tests.swift b/Tests/StreamFeedsTests/StateLayer/BookmarkListState_Tests.swift new file mode 100644 index 0000000..13eeabc --- /dev/null +++ b/Tests/StreamFeedsTests/StateLayer/BookmarkListState_Tests.swift @@ -0,0 +1,152 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamFeeds +import Testing + +struct BookmarkListState_Tests { + // MARK: - Actions + + @Test func getUpdatesState() async throws { + let client = defaultClientWithBookmarkResponses() + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + let bookmarks = try await bookmarkList.get() + let stateBookmarks = await bookmarkList.state.bookmarks + #expect(bookmarks.count == 2) + #expect(stateBookmarks.count == 2) + #expect(stateBookmarks.map(\.id) == ["user-1-activity-1", "user-1-activity-2"]) + #expect(bookmarks.map(\.id) == stateBookmarks.map(\.id)) + await #expect(bookmarkList.state.canLoadMore == true) + await #expect(bookmarkList.state.pagination?.next == "next-cursor") + } + + @Test func queryMoreBookmarksUpdatesState() async throws { + let client = defaultClientWithBookmarkResponses([ + QueryBookmarksResponse.dummy( + bookmarks: [ + .dummy(activity: .dummy(id: "activity-3"), user: .dummy(id: "user-1")), + .dummy(activity: .dummy(id: "activity-4"), user: .dummy(id: "user-1")) + ], + next: "next-cursor-2" + ) + ]) + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + + // Initial load + _ = try await bookmarkList.get() + let initialState = await bookmarkList.state.bookmarks + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["user-1-activity-1", "user-1-activity-2"]) + + // Load more + let moreBookmarks = try await bookmarkList.queryMoreBookmarks() + let updatedState = await bookmarkList.state.bookmarks + #expect(moreBookmarks.count == 2) + #expect(moreBookmarks.map(\.id) == ["user-1-activity-3", "user-1-activity-4"]) + #expect(updatedState.count == 4) + await #expect(bookmarkList.state.canLoadMore == true) + await #expect(bookmarkList.state.pagination?.next == "next-cursor-2") + } + + // MARK: - WebSocket Events + + @Test func bookmarkUpdatedEventUpdatesState() async throws { + let client = defaultClientWithBookmarkResponses() + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + + // Initial load + _ = try await bookmarkList.get() + let initialState = await bookmarkList.state.bookmarks + #expect(initialState.count == 2) + #expect(initialState.first { $0.id == "user-1-activity-1" }?.activity.text == "Test activity content") + + // Send bookmark updated event + let updatedBookmark = BookmarkResponse.dummy( + activity: .dummy(id: "activity-1", text: "Updated activity content"), + folder: .dummy(id: "folder-1", name: "Test Folder"), + user: .dummy(id: "user-1") + ).toModel() + await client.stateLayerEventPublisher.sendEvent(.bookmarkUpdated(updatedBookmark)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await bookmarkList.state.bookmarks + #expect(updatedState.count == 2) + #expect(updatedState.first { $0.id == "user-1-activity-1" }?.activity.text == "Updated activity content") + } + + @Test func bookmarkFolderUpdatedEventUpdatesState() async throws { + let client = defaultClientWithBookmarkResponses() + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + + // Initial load + _ = try await bookmarkList.get() + let initialState = await bookmarkList.state.bookmarks + #expect(initialState.count == 2) + #expect(initialState.first { $0.id == "user-1-activity-1" }?.folder?.name == "Test Folder") + + // Send bookmark folder updated event + let updatedFolder = BookmarkFolderResponse.dummy(id: "folder-1", name: "Updated Folder Name").toModel() + await client.stateLayerEventPublisher.sendEvent(.bookmarkFolderUpdated(updatedFolder)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await bookmarkList.state.bookmarks + #expect(updatedState.count == 2) + #expect(updatedState.first { $0.id == "user-1-activity-1" }?.folder?.name == "Updated Folder Name") + } + + @Test func bookmarkFolderDeletedEventUpdatesState() async throws { + let client = defaultClientWithBookmarkResponses() + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + + // Initial load + _ = try await bookmarkList.get() + let initialState = await bookmarkList.state.bookmarks + #expect(initialState.count == 2) + #expect(initialState.first { $0.id == "user-1-activity-1" }?.folder?.id == "folder-1") + + // Send bookmark folder deleted event + let deletedFolder = BookmarkFolderResponse.dummy(id: "folder-1", name: "Test Folder").toModel() + await client.stateLayerEventPublisher.sendEvent(.bookmarkFolderDeleted(deletedFolder)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await bookmarkList.state.bookmarks + #expect(updatedState.count == 2) + #expect(updatedState.first { $0.id == "user-1-activity-1" }?.folder == nil) + } + + // MARK: - Helper Methods + + private func defaultClientWithBookmarkResponses( + _ additionalPayloads: [any Encodable] = [] + ) -> FeedsClient { + FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarksResponse.dummy( + bookmarks: [ + .dummy( + activity: .dummy(id: "activity-1"), + folder: .dummy(id: "folder-1", name: "Test Folder"), + user: .dummy(id: "user-1") + ), + .dummy( + activity: .dummy(id: "activity-2"), + folder: .dummy(id: "folder-1", name: "Test Folder"), + user: .dummy(id: "user-1") + ) + ], + next: "next-cursor" + ) + ] + additionalPayloads + ) + ) + } +} diff --git a/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift index b9b5f4a..94d02f8 100644 --- a/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/BookmarkList_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Foundation import StreamCore @testable import StreamFeeds import Testing @@ -16,7 +17,7 @@ struct BookmarkList_Tests { let stateBookmarks = await bookmarkList.state.bookmarks #expect(bookmarks.count == 2) #expect(stateBookmarks.count == 2) - #expect(stateBookmarks.map(\.id) == ["activity-1user-1", "activity-2user-1"]) + #expect(stateBookmarks.map(\.id) == ["user-1-activity-1", "user-1-activity-2"]) #expect(bookmarks.map(\.id) == stateBookmarks.map(\.id)) await #expect(bookmarkList.state.canLoadMore == true) await #expect(bookmarkList.state.pagination?.next == "next-cursor") @@ -38,16 +39,16 @@ struct BookmarkList_Tests { _ = try await bookmarkList.get() let initialState = await bookmarkList.state.bookmarks #expect(initialState.count == 2) - #expect(initialState.map(\.id) == ["activity-1user-1", "activity-2user-1"]) + #expect(initialState.map(\.id) == ["user-1-activity-1", "user-1-activity-2"]) // Load more let moreBookmarks = try await bookmarkList.queryMoreBookmarks() let updatedState = await bookmarkList.state.bookmarks #expect(moreBookmarks.count == 2) - #expect(moreBookmarks.map(\.id) == ["activity-3user-1", "activity-4user-1"]) + #expect(moreBookmarks.map(\.id) == ["user-1-activity-3", "user-1-activity-4"]) #expect(updatedState.count == 4) // The bookmarks should be sorted by createdAt in ascending order (oldest first) - #expect(updatedState.map(\.id) == ["activity-3user-1", "activity-4user-1", "activity-1user-1", "activity-2user-1"]) + #expect(updatedState.map(\.id) == ["user-1-activity-3", "user-1-activity-4", "user-1-activity-1", "user-1-activity-2"]) await #expect(bookmarkList.state.canLoadMore == true) await #expect(bookmarkList.state.pagination?.next == "next-cursor-2") } @@ -113,7 +114,7 @@ struct BookmarkList_Tests { // Load more with custom limit let moreBookmarks = try await bookmarkList.queryMoreBookmarks(limit: 1) #expect(moreBookmarks.count == 1) - #expect(moreBookmarks.first?.id == "activity-3user-1") + #expect(moreBookmarks.first?.id == "user-1-activity-3") } // MARK: - WebSocket Events @@ -125,7 +126,7 @@ struct BookmarkList_Tests { let initialState = await bookmarkList.state.bookmarks #expect(initialState.count == 2) - #expect(initialState.first { $0.id == "activity-1user-1" }?.activity.text == "Test activity content") + #expect(initialState.first { $0.id == "user-1-activity-1" }?.activity.text == "Test activity content") // Send bookmark updated event await client.eventsMiddleware.sendEvent( @@ -140,8 +141,8 @@ struct BookmarkList_Tests { let updatedState = await bookmarkList.state.bookmarks #expect(updatedState.count == 2) - #expect(updatedState.first { $0.id == "activity-1user-1" }?.activity.text == "Updated activity content") - #expect(updatedState.first { $0.id == "activity-2user-1" }?.activity.text == "Test activity content") + #expect(updatedState.first { $0.id == "user-1-activity-1" }?.activity.text == "Updated activity content") + #expect(updatedState.first { $0.id == "user-1-activity-2" }?.activity.text == "Test activity content") } @Test func bookmarkFolderUpdatedEventUpdatesState() async throws { @@ -151,7 +152,7 @@ struct BookmarkList_Tests { let initialState = await bookmarkList.state.bookmarks #expect(initialState.count == 2) - #expect(initialState.first { $0.id == "activity-1user-1" }?.folder?.name == "Test Folder") + #expect(initialState.first { $0.id == "user-1-activity-1" }?.folder?.name == "Test Folder") // Send bookmark folder updated event await client.eventsMiddleware.sendEvent( @@ -162,8 +163,7 @@ struct BookmarkList_Tests { let updatedState = await bookmarkList.state.bookmarks #expect(updatedState.count == 2) - #expect(updatedState.first { $0.id == "activity-1user-1" }?.folder?.name == "Updated Folder Name") - #expect(updatedState.first { $0.id == "activity-2user-1" }?.folder?.name == "Test Folder") + #expect(updatedState.map(\.folder?.name) == ["Updated Folder Name", "Updated Folder Name"], "Same folder") } @Test func bookmarkFolderDeletedEventUpdatesState() async throws { @@ -173,7 +173,7 @@ struct BookmarkList_Tests { let initialState = await bookmarkList.state.bookmarks #expect(initialState.count == 2) - #expect(initialState.first { $0.id == "activity-1user-1" }?.folder?.name == "Test Folder") + #expect(initialState.first { $0.id == "user-1-activity-1" }?.folder?.name == "Test Folder") // Send bookmark folder deleted event await client.eventsMiddleware.sendEvent( @@ -184,8 +184,7 @@ struct BookmarkList_Tests { let updatedState = await bookmarkList.state.bookmarks #expect(updatedState.count == 2) - #expect(updatedState.first { $0.id == "activity-1user-1" }?.folder == nil) - #expect(updatedState.first { $0.id == "activity-2user-1" }?.folder?.name == "Test Folder") + #expect(updatedState.map(\.folder) == [nil, nil], "Same folder") } @Test func bookmarkUpdatedEventForUnrelatedUserDoesNotUpdateState() async throws { @@ -195,7 +194,7 @@ struct BookmarkList_Tests { let initialState = await bookmarkList.state.bookmarks #expect(initialState.count == 2) - #expect(initialState.first { $0.id == "activity-1user-1" }?.activity.text == "Test activity content") + #expect(initialState.first { $0.id == "user-1-activity-1" }?.activity.text == "Test activity content") // Send bookmark updated event for unrelated user await client.eventsMiddleware.sendEvent( @@ -210,8 +209,182 @@ struct BookmarkList_Tests { let updatedState = await bookmarkList.state.bookmarks #expect(updatedState.count == 2) - #expect(updatedState.first { $0.id == "activity-1user-1" }?.activity.text == "Test activity content") - #expect(updatedState.first { $0.id == "activity-2user-1" }?.activity.text == "Test activity content") + #expect(updatedState.first { $0.id == "user-1-activity-1" }?.activity.text == "Test activity content") + #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: - Own Capabilities + + @Test func getCachesCapabilities() async throws { + let feed1Id = FeedId(rawValue: "user:feed-1") + let feed2Id = FeedId(rawValue: "user:feed-2") + let feed3Id = FeedId(rawValue: "user:feed-3") + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarksResponse.dummy( + bookmarks: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed1Id.rawValue, ownCapabilities: [.readFeed, .addActivityBookmark]), + id: "activity-1" + ), + user: .dummy(id: "user-1") + ), + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed2Id.rawValue, ownCapabilities: [.readFeed, .deleteOwnActivityBookmark]), + id: "activity-2" + ), + user: .dummy(id: "user-1") + ), + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed3Id.rawValue, ownCapabilities: [.readFeed, .updateOwnActivityBookmark]), + id: "activity-3" + ), + user: .dummy(id: "user-1") + ) + ], + next: nil + ) + ] + ) + ) + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + _ = try await bookmarkList.get() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feed1Id: [.readFeed, .addActivityBookmark], + feed2Id: [.readFeed, .deleteOwnActivityBookmark], + feed3Id: [.readFeed, .updateOwnActivityBookmark] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let bookmarkListCapabilities: (BookmarkList) async -> [FeedId: Set] = { bookmarkList in + let pairs = await bookmarkList.state.bookmarks.compactMap { bookmark -> (FeedId, Set)? in + guard let feedData = bookmark.activity.currentFeed, let capabilities = feedData.ownCapabilities else { return nil } + return (feedData.feed, capabilities) + } + return Dictionary(pairs, uniquingKeysWith: { $1 }) + } + let feed1Id = FeedId(group: "user", id: "feed-1") + let feed2Id = FeedId(group: "user", id: "feed-2") + let initialFeed1Capabilities: Set = [.readFeed, .addActivityBookmark] + let initialFeed2Capabilities: Set = [.readFeed, .deleteOwnActivityBookmark] + let initialCapabilities = [feed1Id: initialFeed1Capabilities, feed2Id: initialFeed2Capabilities] + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryBookmarksResponse.dummy( + bookmarks: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed1Id.rawValue, ownCapabilities: Array(initialFeed1Capabilities)), + id: "activity-1" + ), + user: .dummy(id: "user-1") + ), + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed2Id.rawValue, ownCapabilities: Array(initialFeed2Capabilities)), + id: "activity-2" + ), + user: .dummy(id: "user-1") + ) + ], + next: nil + ) + ] + ) + ) + let bookmarkList = client.bookmarkList(for: BookmarksQuery()) + try await bookmarkList.get() + await #expect(bookmarkListCapabilities(bookmarkList) == initialCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await #expect(bookmarkListCapabilities(bookmarkList) == initialCapabilities) + + // Send matching event with updated capabilities for feed1 + let newFeed1Capabilities: Set = [.readFeed, .addActivityBookmark, .updateOwnActivityBookmark] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed1Id: newFeed1Capabilities]) + ) + await #expect(bookmarkListCapabilities(bookmarkList)[feed1Id] == newFeed1Capabilities) + await #expect(bookmarkListCapabilities(bookmarkList)[feed2Id] == initialFeed2Capabilities) + + // Send matching event with updated capabilities for feed2 + let newFeed2Capabilities: Set = [.readFeed, .deleteOwnActivityBookmark, .addActivityBookmark] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed2Id: newFeed2Capabilities]) + ) + await #expect(bookmarkListCapabilities(bookmarkList)[feed1Id] == newFeed1Capabilities) + await #expect(bookmarkListCapabilities(bookmarkList)[feed2Id] == newFeed2Capabilities) } // MARK: - Helper Methods diff --git a/Tests/StreamFeedsTests/StateLayer/CommentList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/CommentList_Tests.swift index 71391ef..fc288ed 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 ea20f5b..e60ba50 100644 --- a/Tests/StreamFeedsTests/StateLayer/CommentReactionList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/CommentReactionList_Tests.swift @@ -16,8 +16,8 @@ struct CommentReactionList_Tests { ) let reactions = try await reactionList.get() let stateReactions = await reactionList.state.reactions - #expect(reactions.map(\.id) == ["current-user-id-like-activity-123"]) - #expect(stateReactions.map(\.id) == ["current-user-id-like-activity-123"]) + #expect(reactions.map(\.id) == ["current-user-id-like-comment-123-activity-123"]) + #expect(stateReactions.map(\.id) == ["current-user-id-like-comment-123-activity-123"]) } @Test func paginationLoadsMoreReactions() async throws { @@ -42,17 +42,17 @@ struct CommentReactionList_Tests { ) // Initial load - #expect(try await reactionList.get().map(\.id) == ["current-user-id-like-activity-123"]) + #expect(try await reactionList.get().map(\.id) == ["current-user-id-like-comment-123-activity-123"]) #expect(await reactionList.state.canLoadMore == true) // Load more let moreReactions = try await reactionList.queryMoreReactions() - #expect(moreReactions.map(\.id) == ["other-user-heart-activity-123"]) + #expect(moreReactions.map(\.id) == ["other-user-heart-comment-123-activity-123"]) #expect(await reactionList.state.canLoadMore == false) // Check final state let finalStateReactions = await reactionList.state.reactions - #expect(finalStateReactions.map(\.id) == ["current-user-id-like-activity-123", "other-user-heart-activity-123"], "Newest first") + #expect(finalStateReactions.map(\.id) == ["current-user-id-like-comment-123-activity-123", "other-user-heart-comment-123-activity-123"], "Newest first") } @Test func commentReactionAddedEventUpdatesState() async throws { @@ -75,7 +75,7 @@ struct CommentReactionList_Tests { ) let result = await reactionList.state.reactions.map(\.id) - #expect(result == ["other-user-heart-activity-123", "current-user-id-like-activity-123"]) + #expect(result == ["other-user-heart-comment-123-activity-123", "current-user-id-like-comment-123-activity-123"]) let reactions = await reactionList.state.reactions #expect(reactions.map(\.type) == ["heart", "like"]) #expect(reactions.map(\.user.id) == ["other-user", "current-user-id"]) @@ -92,12 +92,13 @@ struct CommentReactionList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionUpdatedEvent.dummy( comment: .dummy(id: Self.commentId, objectId: "activity-123"), - fid: "user:test", reaction: .dummy(commentId: Self.commentId, custom: ["key": .string("UPDATED")], type: "like", user: .dummy(id: "current-user-id")) + fid: "user:test", + reaction: .dummy(commentId: Self.commentId, custom: ["key": .string("UPDATED")], type: "like", user: .dummy(id: "current-user-id")) ) ) let result = await reactionList.state.reactions.map(\.id) - #expect(result == ["current-user-id-like-activity-123"]) + #expect(result == ["current-user-id-like-comment-123-activity-123"]) let updatedReaction = await reactionList.state.reactions.first #expect(updatedReaction?.custom == ["key": RawJSON.string("UPDATED")]) } @@ -113,7 +114,8 @@ struct CommentReactionList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionDeletedEvent.dummy( comment: .dummy(id: Self.commentId, objectId: "activity-123"), - fid: "user:test", reaction: .dummy(commentId: Self.commentId, type: "like", user: .dummy(id: "current-user-id")) + fid: "user:test", + reaction: .dummy(commentId: Self.commentId, type: "like", user: .dummy(id: "current-user-id")) ) ) @@ -132,12 +134,13 @@ struct CommentReactionList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionDeletedEvent.dummy( comment: .dummy(id: "comment-456", objectId: "activity-123"), - fid: "user:test", reaction: .dummy(commentId: "comment-456", type: "like", user: .dummy(id: "current-user-id")) + fid: "user:test", + reaction: .dummy(commentId: "comment-456", type: "like", user: .dummy(id: "current-user-id")) ) ) let result = await reactionList.state.reactions.map(\.id) - #expect(result == ["current-user-id-like-activity-123"]) // Should not be affected + #expect(result == ["current-user-id-like-comment-123-activity-123"]) // Should not be affected } @Test func addedEventsOnlyAffectMatchingComment() async throws { @@ -151,12 +154,13 @@ struct CommentReactionList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionAddedEvent.dummy( comment: .dummy(id: "comment-456", objectId: "activity-123"), - fid: "user:test", reaction: .dummy(commentId: "comment-456", type: "heart", user: .dummy(id: "other-user")) + fid: "user:test", + reaction: .dummy(commentId: "comment-456", type: "heart", user: .dummy(id: "other-user")) ) ) let result = await reactionList.state.reactions.map(\.id) - #expect(result == ["current-user-id-like-activity-123"]) + #expect(result == ["current-user-id-like-comment-123-activity-123"]) let reaction = await reactionList.state.reactions.first #expect(reaction?.type == "like") // Should remain unchanged } @@ -172,16 +176,17 @@ struct CommentReactionList_Tests { await client.eventsMiddleware.sendEvent( CommentReactionUpdatedEvent.dummy( comment: .dummy(id: "comment-456", objectId: "activity-123"), - fid: "user:test", reaction: .dummy(commentId: "comment-456", type: "heart", user: .dummy(id: "current-user-id")) + fid: "user:test", + reaction: .dummy(commentId: "comment-456", type: "heart", user: .dummy(id: "current-user-id")) ) ) let result = await reactionList.state.reactions.map(\.id) - #expect(result == ["current-user-id-like-activity-123"]) + #expect(result == ["current-user-id-like-comment-123-activity-123"]) let reaction = await reactionList.state.reactions.first #expect(reaction?.type == "like") } - + // MARK: - private func defaultClient( diff --git a/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift b/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift new file mode 100644 index 0000000..2f28655 --- /dev/null +++ b/Tests/StreamFeedsTests/StateLayer/EventHandling/Middlewares/OwnCapabilitiesStateLayerEventMiddleware_Tests.swift @@ -0,0 +1,300 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamFeeds +import Testing + +struct OwnCapabilitiesStateLayerEventMiddleware_Tests { + @Test func addCachedCapabilitiesToAddActivityEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + _ = client.ownCapabilitiesRepository.saveCapabilities([FeedId(group: "user", id: "john"): [.readFeed]]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .activityAdded(let activity, _) = event else { return } + continuation.resume(returning: activity) + } + + Task { + await client.eventsMiddleware.sendEvent( + ActivityAddedEvent.dummy( + activity: .dummy(currentFeed: .dummy(feed: "user:john")), + fid: "timeline:jane" + ) + ) + subscription.cancel() + } + } + + #expect(event.currentFeed?.ownCapabilities == [.readFeed]) + } + + @Test func addCachedCapabilitiesToBookmarkAddedEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + _ = client.ownCapabilitiesRepository.saveCapabilities([FeedId(group: "user", id: "john"): [.readFeed, .addActivityBookmark]]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .bookmarkAdded(let bookmark) = event else { return } + continuation.resume(returning: bookmark) + } + + Task { + await client.eventsMiddleware.sendEvent( + BookmarkAddedEvent.dummy( + bookmark: .dummy(activity: .dummy(currentFeed: .dummy(feed: "user:john"))) + ) + ) + subscription.cancel() + } + } + + #expect(event.activity.currentFeed?.ownCapabilities == [.readFeed, .addActivityBookmark]) + } + + @Test func addCachedCapabilitiesToFeedAddedEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + _ = client.ownCapabilitiesRepository.saveCapabilities([FeedId(group: "user", id: "john"): [.readFeed, .createFeed]]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .feedAdded(let feed, _) = event else { return } + continuation.resume(returning: feed) + } + + Task { + await client.eventsMiddleware.sendEvent( + FeedCreatedEvent.dummy( + feed: .dummy(feed: "user:john"), + fid: "user:john" + ) + ) + subscription.cancel() + } + } + + #expect(event.ownCapabilities == [.readFeed, .createFeed]) + } + + @Test func addCachedCapabilitiesToFeedFollowAddedEvent() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + let sourceFeedId = FeedId(group: "user", id: "john") + let targetFeedId = FeedId(group: "user", id: "jane") + _ = client.ownCapabilitiesRepository.saveCapabilities([ + sourceFeedId: [.readFeed, .follow], + targetFeedId: [.readFeed, .queryFollows] + ]) + + let event = await withCheckedContinuation { continuation in + let subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .feedFollowAdded(let follow, _) = event else { return } + continuation.resume(returning: follow) + } + + Task { + await client.eventsMiddleware.sendEvent( + FollowCreatedEvent.dummy( + follow: .dummy( + sourceFeed: .dummy(feed: sourceFeedId.rawValue), + targetFeed: .dummy(feed: targetFeedId.rawValue) + ), + fid: sourceFeedId.rawValue + ) + ) + subscription.cancel() + } + } + + #expect(event.sourceFeed.ownCapabilities == [.readFeed, .follow]) + #expect(event.targetFeed.ownCapabilities == [.readFeed, .queryFollows]) + } + + @Test func automaticallyFetchCapabilitiesOnWebSocketEventWhenNotLocallyCached() async throws { + let client = FeedsClient.mock( + apiTransport: .withPayloads([ + OwnCapabilitiesBatchResponse.dummy(capabilities: [ + "user:john": [.addActivity, .readFeed] + ]) + ]) + ) + var subscription: StateLayerEventPublisher.Subscription? + let capabilities = await withCheckedContinuation { continuation in + // Added event triggers internal fetch of capabilities which in turn sends capabilities updated event + subscription = client.stateLayerEventPublisher.subscribe { event in + guard case .feedOwnCapabilitiesUpdated(let capabilities) = event else { return } + continuation.resume(returning: capabilities) + } + + Task { + await client.eventsMiddleware.sendEvent( + ActivityAddedEvent.dummy( + activity: .dummy(currentFeed: .dummy(feed: "user:john")), + fid: "timeline:jane" + ) + ) + } + } + + #expect(capabilities[FeedId(group: "user", id: "john")] == [.addActivity, .readFeed]) + subscription?.cancel() + } + + @Test func extractCapabilitiesFromLocalEvents() async throws { + let client = FeedsClient.mock(apiTransport: .withPayloads([])) + + let makeActivityData: (String, Set) -> ActivityData = { feedId, ownCapabilities in + ActivityResponse.dummy(currentFeed: .dummy(feed: feedId, ownCapabilities: Array(ownCapabilities))).toModel() + } + let makeFeedData: (String, Set) -> FeedData = { feedId, ownCapabilities in + FeedResponse.dummy(feed: feedId, ownCapabilities: Array(ownCapabilities)).toModel() + } + let makeBookmarkData: (String, Set) -> BookmarkData = { feedId, ownCapabilities in + BookmarkResponse.dummy(activity: .dummy(currentFeed: .dummy(feed: feedId, ownCapabilities: Array(ownCapabilities)))).toModel() + } + let makeCommentData: (String) -> CommentData = { activityId in + CommentResponse.dummy(objectId: activityId).toModel() + } + let makeReactionData: (String) -> FeedsReactionData = { activityId in + FeedsReactionResponse.dummy(activityId: activityId).toModel() + } + let makeActivityPinData: (String, Set) -> ActivityPinData = { feedId, ownCapabilities in + ActivityPinResponse.dummy( + activity: .dummy(currentFeed: .dummy(feed: feedId, ownCapabilities: Array(ownCapabilities))), + feed: feedId + ).toModel() + } + let makeFollowData: (String, String, Set, Set) -> FollowData = { sourceFeedId, targetFeedId, sourceCapabilities, targetCapabilities in + FollowResponse.dummy( + sourceFeed: .dummy(feed: sourceFeedId, ownCapabilities: Array(sourceCapabilities)), + targetFeed: .dummy(feed: targetFeedId, ownCapabilities: Array(targetCapabilities)) + ).toModel() + } + + var feedIdCounter = 1 + + let user1FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user1Capabilities: Set = [.readFeed, .addActivity] + await client.stateLayerEventPublisher.sendEvent(.activityAdded(makeActivityData(user1FeedId.rawValue, user1Capabilities), user1FeedId), source: .local) + feedIdCounter += 1 + + let user2FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user2Capabilities: Set = [.readFeed, .updateOwnActivity] + await client.stateLayerEventPublisher.sendEvent(.activityUpdated(makeActivityData(user2FeedId.rawValue, user2Capabilities), user2FeedId), source: .local) + feedIdCounter += 1 + + let user3FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user3Capabilities: Set = [.readFeed, .addActivityReaction, .addActivity] + await client.stateLayerEventPublisher.sendEvent(.activityReactionAdded(makeReactionData("activity-1"), makeActivityData(user3FeedId.rawValue, user3Capabilities), user3FeedId), source: .local) + feedIdCounter += 1 + + let user4FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user4Capabilities: Set = [.readFeed, .deleteOwnActivityReaction] + await client.stateLayerEventPublisher.sendEvent(.activityReactionDeleted(makeReactionData("activity-2"), makeActivityData(user4FeedId.rawValue, user4Capabilities), user4FeedId), source: .local) + feedIdCounter += 1 + + let user5FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user5Capabilities: Set = [.readFeed, .addActivityReaction] + await client.stateLayerEventPublisher.sendEvent(.activityReactionUpdated(makeReactionData("activity-3"), makeActivityData(user5FeedId.rawValue, user5Capabilities), user5FeedId), source: .local) + feedIdCounter += 1 + + let user6FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user6Capabilities: Set = [.readFeed, .pinActivity] + await client.stateLayerEventPublisher.sendEvent(.activityPinned(makeActivityPinData(user6FeedId.rawValue, user6Capabilities), user6FeedId), source: .local) + feedIdCounter += 1 + + let user7FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user7Capabilities: Set = [.readFeed, .pinActivity, .addActivity] + await client.stateLayerEventPublisher.sendEvent(.activityUnpinned(makeActivityPinData(user7FeedId.rawValue, user7Capabilities), user7FeedId), source: .local) + feedIdCounter += 1 + + let user8FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user8Capabilities: Set = [.readFeed, .addActivityBookmark] + await client.stateLayerEventPublisher.sendEvent(.bookmarkAdded(makeBookmarkData(user8FeedId.rawValue, user8Capabilities)), source: .local) + feedIdCounter += 1 + + let user9FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user9Capabilities: Set = [.readFeed, .deleteOwnActivityBookmark] + await client.stateLayerEventPublisher.sendEvent(.bookmarkDeleted(makeBookmarkData(user9FeedId.rawValue, user9Capabilities)), source: .local) + feedIdCounter += 1 + + let user10FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user10Capabilities: Set = [.readFeed, .updateOwnActivityBookmark] + await client.stateLayerEventPublisher.sendEvent(.bookmarkUpdated(makeBookmarkData(user10FeedId.rawValue, user10Capabilities)), source: .local) + feedIdCounter += 1 + + let user11FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user11Capabilities: Set = [.readFeed, .addComment] + await client.stateLayerEventPublisher.sendEvent(.commentAdded(makeCommentData("activity-4"), makeActivityData(user11FeedId.rawValue, user11Capabilities), user11FeedId), source: .local) + feedIdCounter += 1 + + let user12FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user12Capabilities: Set = [.readFeed, .createFeed] + await client.stateLayerEventPublisher.sendEvent(.feedAdded(makeFeedData(user12FeedId.rawValue, user12Capabilities), user12FeedId), source: .local) + feedIdCounter += 1 + + let user13FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user13Capabilities: Set = [.readFeed, .updateFeed] + await client.stateLayerEventPublisher.sendEvent(.feedUpdated(makeFeedData(user13FeedId.rawValue, user13Capabilities), user13FeedId), source: .local) + feedIdCounter += 1 + + let user14FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user14Capabilities: Set = [.readFeed, .follow] + feedIdCounter += 1 + let user15FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user15Capabilities: Set = [.readFeed, .queryFollows] + await client.stateLayerEventPublisher.sendEvent(.feedFollowAdded(makeFollowData(user14FeedId.rawValue, user15FeedId.rawValue, user14Capabilities, user15Capabilities), user14FeedId), source: .local) + feedIdCounter += 1 + + let user16FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user16Capabilities: Set = [.readFeed, .unfollow] + feedIdCounter += 1 + let user17FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user17Capabilities: Set = [.readFeed, .queryFollows] + await client.stateLayerEventPublisher.sendEvent(.feedFollowDeleted(makeFollowData(user16FeedId.rawValue, user17FeedId.rawValue, user16Capabilities, user17Capabilities), user16FeedId), source: .local) + feedIdCounter += 1 + + let user18FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user18Capabilities: Set = [.readFeed, .follow] + feedIdCounter += 1 + let user19FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user19Capabilities: Set = [.readFeed, .updateFeedFollowers] + await client.stateLayerEventPublisher.sendEvent(.feedFollowUpdated(makeFollowData(user18FeedId.rawValue, user19FeedId.rawValue, user18Capabilities, user19Capabilities), user18FeedId), source: .local) + feedIdCounter += 1 + + let user20FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user20Capabilities: Set = [.readFeed, .addActivity] + feedIdCounter += 1 + let user21FeedId = FeedId(rawValue: "user:user\(feedIdCounter)") + let user21Capabilities: Set = [.readFeed, .updateOwnActivity] + let batchUpdates = ModelUpdates( + added: [makeActivityData(user20FeedId.rawValue, user20Capabilities)], + removedIds: [], + updated: [makeActivityData(user21FeedId.rawValue, user21Capabilities)] + ) + await client.stateLayerEventPublisher.sendEvent(.activityBatchUpdate(batchUpdates), source: .local) + + #expect(client.ownCapabilitiesRepository.capabilities(for: user1FeedId) == user1Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user2FeedId) == user2Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user3FeedId) == user3Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user4FeedId) == user4Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user5FeedId) == user5Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user6FeedId) == user6Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user7FeedId) == user7Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user8FeedId) == user8Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user9FeedId) == user9Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user10FeedId) == user10Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user11FeedId) == user11Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user12FeedId) == user12Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user13FeedId) == user13Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user14FeedId) == user14Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user15FeedId) == user15Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user16FeedId) == user16Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user17FeedId) == user17Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user18FeedId) == user18Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user19FeedId) == user19Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user20FeedId) == user20Capabilities) + #expect(client.ownCapabilitiesRepository.capabilities(for: user21FeedId) == user21Capabilities) + } +} diff --git a/Tests/StreamFeedsTests/StateLayer/FeedListState_Tests.swift b/Tests/StreamFeedsTests/StateLayer/FeedListState_Tests.swift new file mode 100644 index 0000000..65b42d7 --- /dev/null +++ b/Tests/StreamFeedsTests/StateLayer/FeedListState_Tests.swift @@ -0,0 +1,96 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamFeeds +import Testing + +struct FeedListState_Tests { + // MARK: - Actions + + @Test func getUpdatesState() async throws { + let client = defaultClientWithFeedResponses() + let feedList = client.feedList(for: FeedsQuery()) + let feeds = try await feedList.get() + let stateFeeds = await feedList.state.feeds + #expect(feeds.count == 2) + #expect(stateFeeds.count == 2) + #expect(stateFeeds.map(\.id) == ["feed-1", "feed-2"]) + #expect(feeds.map(\.id) == stateFeeds.map(\.id)) + await #expect(feedList.state.canLoadMore == true) + await #expect(feedList.state.pagination?.next == "next-cursor") + } + + @Test func queryMoreFeedsUpdatesState() async throws { + let client = defaultClientWithFeedResponses([ + QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-3", name: "Test Feed 3"), + .dummy(id: "feed-4", name: "Test Feed 4") + ], + next: "next-cursor-2" + ) + ]) + let feedList = client.feedList(for: FeedsQuery()) + + // Initial load + _ = try await feedList.get() + let initialState = await feedList.state.feeds + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["feed-1", "feed-2"]) + + // Load more + let moreFeeds = try await feedList.queryMoreFeeds() + let updatedState = await feedList.state.feeds + #expect(moreFeeds.count == 2) + #expect(moreFeeds.map(\.id) == ["feed-3", "feed-4"]) + #expect(updatedState.count == 4) + await #expect(feedList.state.canLoadMore == true) + await #expect(feedList.state.pagination?.next == "next-cursor-2") + } + + // MARK: - WebSocket Events + + @Test func feedUpdatedEventUpdatesState() async throws { + let client = defaultClientWithFeedResponses() + let feedList = client.feedList(for: FeedsQuery()) + + // Initial load + _ = try await feedList.get() + let initialState = await feedList.state.feeds + #expect(initialState.count == 2) + #expect(initialState.first { $0.id == "feed-1" }?.name == "Test Feed 1") + + // Send feed updated event + let updatedFeed = FeedResponse.dummy(id: "feed-1", name: "Updated Feed Name").toModel() + await client.stateLayerEventPublisher.sendEvent(.feedUpdated(updatedFeed, FeedId(rawValue: "user:feed-1"))) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await feedList.state.feeds + #expect(updatedState.count == 2) + #expect(updatedState.first { $0.id == "feed-1" }?.name == "Updated Feed Name") + } + + // MARK: - Helper Methods + + private func defaultClientWithFeedResponses( + _ additionalPayloads: [any Encodable] = [] + ) -> FeedsClient { + FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-1", name: "Test Feed 1"), + .dummy(id: "feed-2", name: "Test Feed 2") + ], + next: "next-cursor" + ) + ] + additionalPayloads + ) + ) + } +} diff --git a/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift index 9b8a61d..fdc21b6 100644 --- a/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/FeedList_Tests.swift @@ -2,6 +2,8 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Combine +import Foundation import StreamCore @testable import StreamFeeds import Testing @@ -24,12 +26,15 @@ struct FeedList_Tests { @Test func queryMoreFeedsUpdatesState() async throws { let client = defaultClientWithFeedsResponses([ - QueryFeedsResponse.dummy( - feeds: [ - .dummy(createdAt: Date.fixed(), id: "feed-3", name: "Third Feed"), - .dummy(createdAt: Date.fixed(), id: "feed-4", name: "Fourth Feed") - ], - next: "next-cursor-2" + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: QueryFeedsResponse.dummy( + feeds: [ + .dummy(id: "feed-3", name: "Third Feed", createdAt: Date.fixed()), + .dummy(id: "feed-4", name: "Fourth Feed", createdAt: Date.fixed()) + ], + next: "next-cursor-2" + ) ) ]) let feedList = client.feedList(for: FeedsQuery()) @@ -57,8 +62,8 @@ struct FeedList_Tests { apiTransport: .withPayloads([ QueryFeedsResponse.dummy( feeds: [ - .dummy(createdAt: Date.fixed(), id: "feed-1", name: "First Feed"), - .dummy(createdAt: Date.fixed(), id: "feed-2", name: "Second Feed") + .dummy(id: "feed-1", name: "First Feed", createdAt: Date.fixed()), + .dummy(id: "feed-2", name: "Second Feed", createdAt: Date.fixed()) ], next: nil ) @@ -110,7 +115,7 @@ struct FeedList_Tests { // Send feed updated event await client.eventsMiddleware.sendEvent( FeedUpdatedEvent.dummy( - feed: .dummy(createdAt: Date.fixed(), id: "feed-1", name: "Updated First Feed"), + feed: .dummy(id: "feed-1", name: "Updated First Feed", createdAt: Date.fixed()), fid: "user:test" ) ) @@ -133,7 +138,7 @@ struct FeedList_Tests { // Send feed updated event for unrelated feed await client.eventsMiddleware.sendEvent( FeedUpdatedEvent.dummy( - feed: .dummy(createdAt: Date.fixed(), id: "unrelated-feed", name: "Unrelated Feed"), + feed: .dummy(id: "unrelated-feed", name: "Unrelated Feed", createdAt: Date.fixed()), fid: "user:other" ) ) @@ -144,20 +149,231 @@ struct FeedList_Tests { #expect(updatedState.first { $0.id == "feed-2" }?.name == "Second Feed") } - // MARK: - Helper Methods + @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() - private func defaultClientWithFeedsResponses( - _ additionalPayloads: [any Encodable] = [] - ) -> FeedsClient { - FeedsClient.mock( + // 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 + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: + 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: - Own Capabilities + + @Test func getCachesCapabilities() async throws { + let feed1Id = FeedId(rawValue: "user:feed-1") + let feed2Id = FeedId(rawValue: "user:feed-2") + let feed3Id = FeedId(rawValue: "user:feed-3") + + let client = FeedsClient.mock( apiTransport: .withPayloads( [ QueryFeedsResponse.dummy( feeds: [ - .dummy(createdAt: Date.fixed(), id: "feed-1", name: "First Feed"), - .dummy(createdAt: Date.fixed(), id: "feed-2", name: "Second Feed") + .dummy( + createdAt: Date.fixed(), + feed: feed1Id.rawValue, + ownCapabilities: [.readFeed, .createFeed] + ), + .dummy( + createdAt: Date.fixed(offset: 1), + feed: feed2Id.rawValue, + ownCapabilities: [.readFeed, .updateFeed] + ), + .dummy( + createdAt: Date.fixed(offset: 2), + feed: feed3Id.rawValue, + ownCapabilities: [.readFeed, .deleteFeed] + ) ], - next: "next-cursor" + next: nil + ) + ] + ) + ) + let feedList = client.feedList(for: FeedsQuery()) + _ = try await feedList.get() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feed1Id: [.readFeed, .createFeed], + feed2Id: [.readFeed, .updateFeed], + feed3Id: [.readFeed, .deleteFeed] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let feedListCapabilities: (FeedList) async -> [FeedId: Set] = { feedList in + let pairs = await feedList.state.feeds.map { ($0.feed, $0.ownCapabilities) } + return Dictionary(uniqueKeysWithValues: pairs).compactMapValues { $0 } + } + let feed1Id = FeedId(group: "user", id: "feed-1") + let feed2Id = FeedId(group: "user", id: "feed-2") + let initialFeed1Capabilities: Set = [.readFeed, .createFeed] + let initialFeed2Capabilities: Set = [.readFeed, .updateFeed] + let initialCapabilities = [feed1Id: initialFeed1Capabilities, feed2Id: initialFeed2Capabilities] + + let client = FeedsClient.mock( + apiTransport: .withMatchedResponses( + [ + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: + QueryFeedsResponse.dummy( + feeds: [ + .dummy( + createdAt: Date.fixed(), + feed: feed1Id.rawValue, + ownCapabilities: Array(initialFeed1Capabilities) + ), + .dummy( + createdAt: Date.fixed(offset: 1), + feed: feed2Id.rawValue, + ownCapabilities: Array(initialFeed2Capabilities) + ) + ], + next: nil + ) + ) + ] + ) + ) + let feedList = client.feedList(for: FeedsQuery()) + try await feedList.get() + await #expect(feedListCapabilities(feedList) == initialCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await #expect(feedListCapabilities(feedList) == initialCapabilities) + + // Send matching event with updated capabilities for feed1 + let newFeed1Capabilities: Set = [.readFeed, .createFeed, .deleteFeed] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed1Id: newFeed1Capabilities]) + ) + await #expect(feedListCapabilities(feedList)[feed1Id] == newFeed1Capabilities) + await #expect(feedListCapabilities(feedList)[feed2Id] == initialFeed2Capabilities) + + // Send matching event with updated capabilities for feed2 + let newFeed2Capabilities: Set = [.readFeed, .updateFeed, .updateFeedMembers] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feed2Id: newFeed2Capabilities]) + ) + await #expect(feedListCapabilities(feedList)[feed1Id] == newFeed1Capabilities) + await #expect(feedListCapabilities(feedList)[feed2Id] == newFeed2Capabilities) + } + + // MARK: - Helper Methods + + private func defaultClientWithFeedsResponses( + _ additionalPayloads: [APITransportMock.APIResponse] = [] + ) -> FeedsClient { + FeedsClient.mock( + apiTransport: .withMatchedResponses( + [ + .init( + matching: .bodyType(QueryFeedsRequest.self), + payload: 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)) + ], + next: "next-cursor" + ) ) ] + additionalPayloads ) diff --git a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift index b6bc4ff..5c188de 100644 --- a/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/Feed_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // +import Foundation import StreamCore @testable import StreamFeeds import Testing @@ -26,8 +27,8 @@ struct Feed_Tests { let client = defaultClientWithActivities(feed: feedId.rawValue, [ UpdateFeedResponse.dummy( feed: .dummy( - custom: customData, - name: "Updated Feed Name" + name: "Updated Feed Name", + custom: customData ) ) ]) @@ -38,7 +39,8 @@ struct Feed_Tests { let updatedFeedData = try await feed.updateFeed(request: updateRequest) let stateFeedData = await feed.state.feedData - #expect(stateFeedData == updatedFeedData) + #expect(updatedFeedData.name == "Updated Feed Name") + #expect(updatedFeedData.custom == customData) #expect(stateFeedData?.name == "Updated Feed Name") #expect(stateFeedData?.custom == customData) } @@ -944,6 +946,95 @@ struct Feed_Tests { await #expect(feed.state.activities.first?.text == "New From WS") } + + @Test func activityBatchUpdateEventUpdatesState() async throws { + let feedId = FeedId(group: "user", id: "jane") + let client = defaultClientWithActivities( + feed: feedId.rawValue, + [ + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + feeds: [feedId.rawValue], + id: "2" + ) + ], + duration: "1.23ms" + ), + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + feeds: [feedId.rawValue], + id: "1", + text: "UPDATED TEXT" + ) + ], + duration: "1.23ms" + ), + DeleteActivitiesResponse.dummy( + deletedIds: ["1"] + ), + UpsertActivitiesResponse( + activities: [ + ActivityResponse.dummy( + createdAt: .fixed(), + editedAt: .fixed(), + feeds: ["user:someoneelse"], + id: "unrelated-activity" + ) + ], + duration: "1.23ms" + ) + ] + ) + let feed = client.feed(for: feedId) + try await feed.getOrCreate() + + await #expect(feed.state.activities.map(\.id) == ["1"]) + await #expect(feed.state.pinnedActivities.map(\.activity.id) == ["1"]) + + // Send batch update with added activity + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [feedId.rawValue], + id: "2", + type: "post" + ) + ]) + await #expect(feed.state.activities.map(\.id).sorted() == ["1", "2"]) + + // Send batch update with updated activity + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: [feedId.rawValue], + id: "1", + text: "UPDATED TEXT", + type: "post" + ) + ]) + let afterUpdate = await feed.state.activities + let updatedActivity = try #require(afterUpdate.first(where: { $0.id == "1" })) + #expect(updatedActivity.text == "UPDATED TEXT") + + // Send batch update with removed activity - should remove from both activities and pinnedActivities + _ = try await client.deleteActivities( + request: DeleteActivitiesRequest(ids: ["1"]) + ) + await #expect(feed.state.activities.map(\.id) == ["2"]) + await #expect(feed.state.pinnedActivities.isEmpty) + + // Send batch update with unrelated activity - should be ignored + _ = try await client.upsertActivities([ + ActivityRequest( + feeds: ["user:someoneelse"], + id: "unrelated-activity", + type: "post" + ) + ]) + await #expect(feed.state.activities.map(\.id) == ["2"]) + } @Test func activityPinnedEventUpdatesState() async throws { let feedId = FeedId(group: "user", id: "jane") @@ -1256,7 +1347,7 @@ struct Feed_Tests { // Send unmatching update first - should be ignored await client.eventsMiddleware.sendEvent( FeedUpdatedEvent.dummy( - feed: .dummy(feed: "user:someoneelse", name: "Ignored") + feed: .dummy(name: "Ignored", feed: "user:someoneelse") ) ) @@ -1265,7 +1356,7 @@ struct Feed_Tests { // Send matching update - should refresh feed data await client.eventsMiddleware.sendEvent( FeedUpdatedEvent.dummy( - feed: .dummy(feed: feedId.rawValue, name: "New Name") + feed: .dummy(name: "New Name", feed: feedId.rawValue) ) ) @@ -1383,6 +1474,95 @@ struct Feed_Tests { await #expect(feed.state.followRequests.map(\.status) == []) } + // MARK: - Own Capabilities + + @Test func getOrCreateCachesCapabilities() async throws { + let feedId = FeedId(group: "user", id: "jane") + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + GetOrCreateFeedResponse.dummy( + activities: [ + .dummy( + currentFeed: .dummy(feed: "user:john", ownCapabilities: [.readFeed, .addActivityBookmark]), id: "1" + ) + ], + feed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]), + followers: [ + FollowResponse.dummy( + sourceFeed: .dummy(feed: "user:bob", ownCapabilities: [.readFeed, .addActivityReaction]), + status: .pending, + targetFeed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]) + ), + FollowResponse.dummy( + sourceFeed: .dummy(feed: "user:alice", ownCapabilities: [.readFeed, .addComment]), + targetFeed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]) + ) + ], + following: [ + FollowResponse.dummy( + sourceFeed: .dummy(feed: feedId.rawValue, ownCapabilities: [.readFeed, .addActivity]), + targetFeed: .dummy(feed: "user:melissa", ownCapabilities: [.readFeed, .addCommentReaction]) + ) + ], + members: [.dummy(user: .dummy(id: "feed-member-1"))], + pinnedActivities: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: "user:lisa", ownCapabilities: [.readFeed, .createFeed]), id: "1" + ), + feed: feedId.rawValue + ) + ] + ) + ] + ) + ) + let feed = client.feed(for: feedId) + _ = try await feed.getOrCreate() + + let expectedCapabilitiesMap: [FeedId: Set] = [ + feedId: [.readFeed, .addActivity], + FeedId(rawValue: "user:john"): [.readFeed, .addActivityBookmark], + FeedId(rawValue: "user:bob"): [.readFeed, .addActivityReaction], + FeedId(rawValue: "user:alice"): [.readFeed, .addComment], + FeedId(rawValue: "user:melissa"): [.readFeed, .addCommentReaction], + FeedId(rawValue: "user:lisa"): [.readFeed, .createFeed] + ] + + let cached = try #require(client.ownCapabilitiesRepository.capabilities(for: Set(expectedCapabilitiesMap.keys))) + #expect(cached.keys.map(\.rawValue).sorted() == expectedCapabilitiesMap.keys.map(\.rawValue).sorted()) + + for (feedId, expectedCapabilities) in expectedCapabilitiesMap { + #expect(cached[feedId] == expectedCapabilities) + } + } + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let feedId = FeedId(group: "user", id: "jane") + let client = defaultClientWithActivities(feed: feedId.rawValue) + let feed = client.feed(for: feedId) + try await feed.getOrCreate() + + let initialCapabilities: Set = [.readFeed, .addActivity] + await verifyOwnCapabilities(in: feed, equalTo: initialCapabilities, for: feedId) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + await verifyOwnCapabilities(in: feed, equalTo: initialCapabilities, for: feedId) + + // Send matching event with updated capabilities + let newCapabilities: Set = [.readFeed, .addActivity, .deleteOwnActivity] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([feedId: newCapabilities]) + ) + await verifyOwnCapabilities(in: feed, equalTo: newCapabilities, for: feedId) + } + // MARK: - private func defaultClientWithActivities( @@ -1393,33 +1573,76 @@ struct Feed_Tests { apiTransport: .withPayloads( [ GetOrCreateFeedResponse.dummy( - activities: [.dummy(id: "1")], - feed: .dummy(feed: feed), + activities: [ + .dummy( + currentFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), id: "1" + ) + ], + feed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), followers: [ FollowResponse.dummy( sourceFeed: .dummy(feed: "user:bob"), status: .pending, - targetFeed: .dummy(feed: feed) + targetFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]) ), FollowResponse.dummy( sourceFeed: .dummy(feed: "user:bob"), - targetFeed: .dummy(feed: feed) + targetFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]) ) ], following: [ FollowResponse.dummy( - sourceFeed: .dummy(feed: feed), + sourceFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), targetFeed: .dummy(feed: "user:bob") ) ], members: [.dummy(user: .dummy(id: "feed-member-1"))], - pinnedActivities: [.dummy( - activity: .dummy(id: "1"), - feed: feed - )] + pinnedActivities: [ + .dummy( + activity: .dummy( + currentFeed: .dummy(feed: feed, ownCapabilities: [.readFeed, .addActivity]), id: "1" + ), + feed: feed + ) + ] ) ] + additionalPayloads ) ) } + + private func verifyOwnCapabilities(in feed: Feed, equalTo expectedCapabilities: Set, for feedId: FeedId) async { + await #expect(feed.state.ownCapabilities == expectedCapabilities) + await #expect(feed.state.feedData?.ownCapabilities == expectedCapabilities) + for activity in await feed.state.activities where activity.currentFeed?.feed == feedId { + #expect(activity.currentFeed?.ownCapabilities == expectedCapabilities) + } + for pinnedActivity in await feed.state.pinnedActivities where pinnedActivity.activity.currentFeed?.feed == feedId { + #expect(pinnedActivity.activity.currentFeed?.ownCapabilities == expectedCapabilities) + } + for follower in await feed.state.followers { + if follower.sourceFeed.feed == feedId { + #expect(follower.sourceFeed.ownCapabilities == expectedCapabilities) + } + if follower.targetFeed.feed == feedId { + #expect(follower.targetFeed.ownCapabilities == expectedCapabilities) + } + } + for follow in await feed.state.following { + if follow.sourceFeed.feed == feedId { + #expect(follow.sourceFeed.ownCapabilities == expectedCapabilities) + } + if follow.targetFeed.feed == feedId { + #expect(follow.targetFeed.ownCapabilities == expectedCapabilities) + } + } + for followRequest in await feed.state.followRequests { + if followRequest.sourceFeed.feed == feedId { + #expect(followRequest.sourceFeed.ownCapabilities == expectedCapabilities) + } + if followRequest.targetFeed.feed == feedId { + #expect(followRequest.targetFeed.ownCapabilities == expectedCapabilities) + } + } + } } diff --git a/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift index 6bd1d25..a18b801 100644 --- a/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift +++ b/Tests/StreamFeedsTests/StateLayer/FollowList_Tests.swift @@ -123,6 +123,105 @@ 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: - Own Capabilities + + @Test func feedOwnCapabilitiesUpdatedEventUpdatesState() async throws { + let sourceFeedId = FeedId(group: "user", id: "current-user-id") + let targetFeedId = FeedId(group: "user", id: "user-1") + let initialSourceCapabilities: Set = [.readFeed, .follow] + let initialTargetCapabilities: Set = [.readFeed, .queryFollows] + + let client = FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryFollowsResponse.dummy( + follows: [ + .dummy( + sourceFeed: .dummy(feed: sourceFeedId.rawValue, ownCapabilities: Array(initialSourceCapabilities)), + targetFeed: .dummy(feed: targetFeedId.rawValue, ownCapabilities: Array(initialTargetCapabilities)) + ) + ], + next: nil + ) + ] + ) + ) + let followList = client.followList( + for: FollowsQuery(filter: .equal(.sourceFeed, sourceFeedId.rawValue)) + ) + try await followList.get() + + let initialFollow = try #require(await followList.state.follows.first) + #expect(initialFollow.sourceFeed.ownCapabilities == initialSourceCapabilities) + #expect(initialFollow.targetFeed.ownCapabilities == initialTargetCapabilities) + + // Send unmatching event first - should be ignored + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([ + FeedId(rawValue: "user:someoneelse"): [.readFeed, .addActivity, .deleteOwnActivity] + ]) + ) + let followAfterUnmatching = try #require(await followList.state.follows.first) + #expect(followAfterUnmatching.sourceFeed.ownCapabilities == initialSourceCapabilities) + #expect(followAfterUnmatching.targetFeed.ownCapabilities == initialTargetCapabilities) + + // Send matching event with updated capabilities for source feed + let newSourceCapabilities: Set = [.readFeed, .follow, .unfollow] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([sourceFeedId: newSourceCapabilities]) + ) + let followAfterSourceUpdate = try #require(await followList.state.follows.first) + #expect(followAfterSourceUpdate.sourceFeed.ownCapabilities == newSourceCapabilities) + #expect(followAfterSourceUpdate.targetFeed.ownCapabilities == initialTargetCapabilities) + + // Send matching event with updated capabilities for target feed + let newTargetCapabilities: Set = [.readFeed, .queryFollows, .updateFeedFollowers] + await client.stateLayerEventPublisher.sendEvent( + .feedOwnCapabilitiesUpdated([targetFeedId: newTargetCapabilities]) + ) + let followAfterTargetUpdate = try #require(await followList.state.follows.first) + #expect(followAfterTargetUpdate.sourceFeed.ownCapabilities == newSourceCapabilities) + #expect(followAfterTargetUpdate.targetFeed.ownCapabilities == newTargetCapabilities) + } // MARK: - diff --git a/Tests/StreamFeedsTests/StateLayer/MemberListState_Tests.swift b/Tests/StreamFeedsTests/StateLayer/MemberListState_Tests.swift new file mode 100644 index 0000000..11b55ed --- /dev/null +++ b/Tests/StreamFeedsTests/StateLayer/MemberListState_Tests.swift @@ -0,0 +1,179 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamFeeds +import Testing + +struct MemberListState_Tests { + // MARK: - Actions + + @Test func getUpdatesState() async throws { + let client = defaultClientWithMemberResponses() + let memberList = client.memberList(for: MembersQuery(feed: FeedId(rawValue: "user:test"))) + let members = try await memberList.get() + let stateMembers = await memberList.state.members + #expect(members.count == 2) + #expect(stateMembers.count == 2) + #expect(stateMembers.map(\.id) == ["user-1", "user-2"]) + #expect(members.map(\.id) == stateMembers.map(\.id)) + await #expect(memberList.state.canLoadMore == true) + await #expect(memberList.state.pagination?.next == "next-cursor") + } + + @Test func queryMoreMembersUpdatesState() async throws { + let client = defaultClientWithMemberResponses([ + QueryFeedMembersResponse.dummy( + members: [ + .dummy(user: .dummy(id: "user-3")), + .dummy(user: .dummy(id: "user-4")) + ], + next: "next-cursor-2" + ) + ]) + let memberList = client.memberList(for: MembersQuery(feed: FeedId(rawValue: "user:test"))) + + // Initial load + _ = try await memberList.get() + let initialState = await memberList.state.members + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["user-1", "user-2"]) + + // Load more + let moreMembers = try await memberList.queryMoreMembers() + let updatedState = await memberList.state.members + #expect(moreMembers.count == 2) + #expect(moreMembers.map(\.id) == ["user-3", "user-4"]) + #expect(updatedState.count == 4) + await #expect(memberList.state.canLoadMore == true) + await #expect(memberList.state.pagination?.next == "next-cursor-2") + } + + // MARK: - WebSocket Events + + @Test func feedMemberUpdatedEventUpdatesState() async throws { + let feedId = FeedId(rawValue: "user:test") + let client = defaultClientWithMemberResponses() + let memberList = client.memberList(for: MembersQuery(feed: feedId)) + + // Initial load + _ = try await memberList.get() + let initialState = await memberList.state.members + #expect(initialState.count == 2) + #expect(initialState.first { $0.id == "user-1" }?.user.id == "user-1") + + // Send feed member updated event + let updatedMember = FeedMemberResponse.dummy( + user: .dummy(id: "user-1", name: "Updated User Name") + ).toModel() + await client.stateLayerEventPublisher.sendEvent(.feedMemberUpdated(updatedMember, feedId)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await memberList.state.members + #expect(updatedState.count == 2) + #expect(updatedState.first { $0.id == "user-1" }?.user.name == "Updated User Name") + } + + @Test func feedMemberDeletedEventUpdatesState() async throws { + let feedId = FeedId(rawValue: "user:test") + let client = defaultClientWithMemberResponses() + let memberList = client.memberList(for: MembersQuery(feed: feedId)) + + // Initial load + _ = try await memberList.get() + let initialState = await memberList.state.members + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["user-1", "user-2"]) + + // Send feed member deleted event + await client.stateLayerEventPublisher.sendEvent(.feedMemberDeleted("user-1", feedId)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await memberList.state.members + #expect(updatedState.count == 1) + #expect(updatedState.map(\.id) == ["user-2"]) + } + + @Test func feedMemberBatchUpdateEventUpdatesState() async throws { + let feedId = FeedId(rawValue: "user:test") + let client = defaultClientWithMemberResponses() + let memberList = client.memberList(for: MembersQuery(feed: feedId)) + + // Initial load + _ = try await memberList.get() + let initialState = await memberList.state.members + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["user-1", "user-2"]) + + // Send feed member batch update event + let updatedMember = FeedMemberResponse.dummy( + user: .dummy(id: "user-1", name: "Updated User Name") + ).toModel() + let updates = ModelUpdates( + added: [], + removedIds: ["user-2"], + updated: [updatedMember] + ) + await client.stateLayerEventPublisher.sendEvent(.feedMemberBatchUpdate(updates, feedId)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await memberList.state.members + #expect(updatedState.count == 1) + #expect(updatedState.first?.id == "user-1") + #expect(updatedState.first?.user.name == "Updated User Name") + } + + @Test func feedMemberEventForDifferentFeedIsIgnored() async throws { + let feedId = FeedId(rawValue: "user:test") + let otherFeedId = FeedId(rawValue: "user:other") + let client = defaultClientWithMemberResponses() + let memberList = client.memberList(for: MembersQuery(feed: feedId)) + + // Initial load + _ = try await memberList.get() + let initialState = await memberList.state.members + #expect(initialState.count == 2) + #expect(initialState.map(\.id) == ["user-1", "user-2"]) + + // Send feed member updated event for different feed + let updatedMember = FeedMemberResponse.dummy( + user: .dummy(id: "user-1-updated") + ).toModel() + await client.stateLayerEventPublisher.sendEvent(.feedMemberUpdated(updatedMember, otherFeedId)) + + // Wait a bit for the event to be processed + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + let updatedState = await memberList.state.members + #expect(updatedState.count == 2) + #expect(updatedState.map(\.id) == ["user-1", "user-2"]) + #expect(updatedState.first { $0.id == "user-1" }?.user.id == "user-1") // Should not be updated + } + + // MARK: - Helper Methods + + private func defaultClientWithMemberResponses( + _ additionalPayloads: [any Encodable] = [] + ) -> FeedsClient { + FeedsClient.mock( + apiTransport: .withPayloads( + [ + QueryFeedMembersResponse.dummy( + members: [ + .dummy(user: .dummy(id: "user-1")), + .dummy(user: .dummy(id: "user-2")) + ], + next: "next-cursor" + ) + ] + additionalPayloads + ) + ) + } +} diff --git a/Tests/StreamFeedsTests/StateLayer/MemberList_Tests.swift b/Tests/StreamFeedsTests/StateLayer/MemberList_Tests.swift index f20c1aa..1b98a34 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 94e7cfa..bd8b8f4 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 d5fb4ae..9697b7a 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/StreamFeeds.xctestplan b/Tests/StreamFeedsTests/StreamFeeds.xctestplan index 9bc18ee..b5f3bea 100644 --- a/Tests/StreamFeedsTests/StreamFeeds.xctestplan +++ b/Tests/StreamFeedsTests/StreamFeeds.xctestplan @@ -18,7 +18,7 @@ } ] }, - "defaultTestExecutionTimeAllowance" : 60, + "defaultTestExecutionTimeAllowance" : 180, "language" : "en", "region" : "US", "testTimeoutsEnabled" : true diff --git a/Tests/StreamFeedsTests/TestTools/APITransportMock.swift b/Tests/StreamFeedsTests/TestTools/APITransportMock.swift index e2a727a..41c9fe1 100644 --- a/Tests/StreamFeedsTests/TestTools/APITransportMock.swift +++ b/Tests/StreamFeedsTests/TestTools/APITransportMock.swift @@ -7,47 +7,84 @@ import StreamCore import StreamFeeds final class APITransportMock: DefaultAPITransport { - let responsePayloads = AllocatedUnfairLock<[any Encodable]>([]) - - func execute(request: StreamCore.Request) async throws -> (Data, URLResponse) { - let payload = try responsePayloads.withLock { payloads in - try Self.consumeResponsePayload(for: request, from: &payloads) + let responseResults = AllocatedUnfairLock<[APIResponse]>([]) + + func execute(request: Request) async throws -> (Data, URLResponse) { + let response = try consumeResponseResult(for: request) + switch response { + case .success(let payload): + let data = try CodableHelper.jsonEncoder.encode(payload) + let response = HTTPURLResponse( + url: request.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (data, response) + case .failure(let failure): + throw failure } - let data = try CodableHelper.jsonEncoder.encode(payload) - let response = HTTPURLResponse( - url: request.url, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - return (data, response) } - - private static func consumeResponsePayload(for request: StreamCore.Request, from payloads: inout [any Encodable]) throws -> any Encodable { - let payloadIndex = payloads.firstIndex { payload in - switch payload.self { - case is GetActivityResponse: - request.url.path.hasPrefix("/api/v2/feeds/activities/") - case is GetCommentsResponse: - request.url.path.hasPrefix("/api/v2/feeds/comments") - default: - // Otherwise just pick the first. Custom matching is needed only for tests which run API - // requests in parallel so the order of responsePayload does not match with the order of - // execute(request:) calls. - true + + func consumeResponseResult(for request: Request) throws -> Result { + try responseResults.withLock { responseResults in + let matchingIndex = responseResults.firstIndex { response in + switch response.matching { + case .any: + return true + case .pathPrefix(let prefix): + return request.url.path.hasPrefix(prefix) + case .bodyType(let bodyType): + guard let body = request.body else { return false } + return (try? CodableHelper.jsonDecoder.decode(bodyType, from: body)) != nil + } + } + guard let matchingIndex else { + throw ClientError.Unexpected("Mocked API request is not set for \(request)") } + return responseResults.remove(at: matchingIndex).result + } + } +} + +extension APITransportMock { + enum RequestMatching { + /// Response is used for any incoming request. + case any + /// Response is used only if path has the specified prefix. + case pathPrefix(String) + /// Response is used only if ``Request\body`` has data matching with the specified type. + /// + /// Example: `QueryFeedsRequest` should be matched when response is `QueryFeedsResponse` + case bodyType(Decodable.Type) + } + + struct APIResponse { + let matching: RequestMatching + let result: Result + + init(matching: RequestMatching, result: Result) { + self.matching = matching + self.result = result } - guard let payloadIndex else { - throw ClientError("Response payload is not available for request: \(request)") + + init(matching: RequestMatching, payload: any Encodable) { + self.matching = matching + self.result = .success(payload) } - return payloads.remove(at: payloadIndex) } } extension APITransportMock { + static func withMatchedResponses(_ responses: [APIResponse]) -> APITransportMock { + let transport = APITransportMock() + transport.responseResults.value = responses + return transport + } + static func withPayloads(_ payloads: [any Encodable]) -> APITransportMock { let transport = APITransportMock() - transport.responsePayloads.value = payloads + transport.responseResults.value = payloads.map { APIResponse(matching: .any, result: .success($0)) } return transport } } diff --git a/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift index 667fa6c..12095d0 100644 --- a/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/ActivityResponse+Testing.swift @@ -11,6 +11,8 @@ extension ActivityResponse { bookmarkCount: Int = 0, comments: [CommentResponse] = [], createdAt: Date = .fixed(), + editedAt: Date? = nil, + currentFeed: FeedResponse? = FeedResponse.dummy(), expiresAt: Date? = nil, feeds: [String] = ["user:test"], id: String = "activity-123", @@ -30,10 +32,10 @@ extension ActivityResponse { commentCount: 1, comments: comments, createdAt: createdAt, - currentFeed: FeedResponse.dummy(), + currentFeed: currentFeed, custom: [:], deletedAt: nil, - editedAt: nil, + editedAt: editedAt, expiresAt: expiresAt, feeds: feeds, filterTags: [], diff --git a/Tests/StreamFeedsTests/TestTools/BookmarkFolderResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/BookmarkFolderResponse+Testing.swift index fb32de5..b8e1714 100644 --- a/Tests/StreamFeedsTests/TestTools/BookmarkFolderResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/BookmarkFolderResponse+Testing.swift @@ -8,11 +8,11 @@ import StreamCore extension BookmarkFolderResponse { static func dummy( - createdAt: Date = .fixed(), - custom: [String: RawJSON]? = nil, id: String = "folder-1", name: String = "Test Folder", - updatedAt: Date = .fixed() + createdAt: Date = .fixed(), + updatedAt: Date = .fixed(), + custom: [String: RawJSON]? = nil ) -> BookmarkFolderResponse { BookmarkFolderResponse( createdAt: createdAt, diff --git a/Tests/StreamFeedsTests/TestTools/DeleteActivitiesResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/DeleteActivitiesResponse+Testing.swift new file mode 100644 index 0000000..dd79054 --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/DeleteActivitiesResponse+Testing.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension DeleteActivitiesResponse { + private static let defaultDuration = "1.23ms" + + static func dummy( + deletedIds: [String] = [], + duration: String = defaultDuration + ) -> DeleteActivitiesResponse { + DeleteActivitiesResponse( + deletedIds: deletedIds, + duration: duration + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/FeedCreatedEvent+Testing.swift b/Tests/StreamFeedsTests/TestTools/FeedCreatedEvent+Testing.swift new file mode 100644 index 0000000..a7bd50a --- /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 + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/FeedMemberResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/FeedMemberResponse+Testing.swift index c52650b..34bee9b 100644 --- a/Tests/StreamFeedsTests/TestTools/FeedMemberResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/FeedMemberResponse+Testing.swift @@ -8,17 +8,23 @@ import StreamCore extension FeedMemberResponse { static func dummy( + user: UserResponse = .dummy(), createdAt: Date = .fixed(), updatedAt: Date = .fixed(), - user: UserResponse + custom: [String: RawJSON]? = nil, + inviteAcceptedAt: Date? = nil, + inviteRejectedAt: Date? = nil, + role: String = "member", + status: FeedMemberResponseStatus = .member ) -> FeedMemberResponse { FeedMemberResponse( createdAt: createdAt, - custom: nil, - inviteAcceptedAt: nil, - inviteRejectedAt: nil, - role: "user", - status: .member, + custom: custom, + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + membershipLevel: nil, + role: role, + status: status, updatedAt: updatedAt, user: user ) diff --git a/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift index fd0010e..d7b5fc1 100644 --- a/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/FeedResponse+Testing.swift @@ -8,24 +8,23 @@ import StreamCore extension FeedResponse { static func dummy( - createdAt: Date = Date.fixed(), - createdBy: UserResponse = UserResponse.dummy(), - custom: [String: RawJSON] = [:], + id: String = "feed-1", + name: String = "Test Feed", + createdAt: Date = .fixed(), + updatedAt: Date = .fixed(), + createdBy: UserResponse = .dummy(), + feed: String = "user:feed-1", + custom: [String: RawJSON]? = nil, deletedAt: Date? = nil, description: String = "Test feed description", - feed: String = "user:test", filterTags: [String]? = nil, - followerCount: Int = 50, - followingCount: Int = 25, + followerCount: Int = 0, + followingCount: Int = 0, groupId: String = "user", - id: String = "feed-123", - memberCount: Int = 1, - name: String = "Test Feed", - ownCapabilities: [FeedOwnCapability] = FeedOwnCapability.allCases, - ownFollows: [FollowResponse]? = nil, - pinCount: Int = 2, - updatedAt: Date = Date.fixed(), - visibility: String? = "public" + memberCount: Int = 0, + ownCapabilities: [FeedOwnCapability]? = [.readFeed, .readActivities], + pinCount: Int = 0, + visibility: String? = nil ) -> FeedResponse { FeedResponse( createdAt: createdAt, @@ -42,10 +41,11 @@ extension FeedResponse { memberCount: memberCount, name: name, ownCapabilities: ownCapabilities, - ownFollows: ownFollows, + ownFollows: nil, + ownMembership: nil, pinCount: pinCount, updatedAt: updatedAt, - visibility: "public" + visibility: visibility ) } } diff --git a/Tests/StreamFeedsTests/TestTools/OwnCapabilitiesBatchResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/OwnCapabilitiesBatchResponse+Testing.swift new file mode 100644 index 0000000..ed7c72e --- /dev/null +++ b/Tests/StreamFeedsTests/TestTools/OwnCapabilitiesBatchResponse+Testing.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore +@testable import StreamFeeds + +extension OwnCapabilitiesBatchResponse { + static func dummy( + capabilities: [String: [FeedOwnCapability]] + ) -> OwnCapabilitiesBatchResponse { + OwnCapabilitiesBatchResponse( + capabilities: capabilities, + duration: "1.23ms" + ) + } +} diff --git a/Tests/StreamFeedsTests/TestTools/QueryBookmarkFoldersResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/QueryBookmarkFoldersResponse+Testing.swift index 015ef29..ed92032 100644 --- a/Tests/StreamFeedsTests/TestTools/QueryBookmarkFoldersResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/QueryBookmarkFoldersResponse+Testing.swift @@ -8,7 +8,7 @@ import StreamCore extension QueryBookmarkFoldersResponse { static func dummy( - bookmarkFolders: [BookmarkFolderResponse] = [.dummy()], + bookmarkFolders: [BookmarkFolderResponse] = [], duration: String = "1.23ms", next: String? = nil, prev: String? = nil diff --git a/Tests/StreamFeedsTests/TestTools/QueryFeedMembersResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/QueryFeedMembersResponse+Testing.swift index ae36a9d..d0d31b1 100644 --- a/Tests/StreamFeedsTests/TestTools/QueryFeedMembersResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/QueryFeedMembersResponse+Testing.swift @@ -9,7 +9,7 @@ import StreamCore extension QueryFeedMembersResponse { static func dummy( duration: String = "1.23ms", - members: [FeedMemberResponse] = [.dummy(user: .dummy(id: "feed-member-1"))], + members: [FeedMemberResponse] = [], next: String? = nil, prev: String? = nil ) -> QueryFeedMembersResponse { diff --git a/Tests/StreamFeedsTests/TestTools/QueryFeedsResponse+Testing.swift b/Tests/StreamFeedsTests/TestTools/QueryFeedsResponse+Testing.swift index fa070df..919043b 100644 --- a/Tests/StreamFeedsTests/TestTools/QueryFeedsResponse+Testing.swift +++ b/Tests/StreamFeedsTests/TestTools/QueryFeedsResponse+Testing.swift @@ -8,9 +8,9 @@ import StreamCore extension QueryFeedsResponse { static func dummy( - duration: String = "0.123s", - feeds: [FeedResponse] = [FeedResponse.dummy()], - next: String? = "next-cursor", + duration: String = "1.23ms", + feeds: [FeedResponse] = [], + next: String? = nil, prev: String? = nil ) -> QueryFeedsResponse { QueryFeedsResponse( diff --git a/fastlane/Fastfile b/fastlane/Fastfile index b97cb84..70dabdc 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -6,7 +6,7 @@ require 'json' require 'net/http' import 'Sonarfile' -xcode_version = ENV['XCODE_VERSION'] || '16.4' +xcode_version = ENV['XCODE_VERSION'] || '26.0.1' xcode_project = 'StreamFeeds.xcodeproj' sdk_names = ['StreamFeeds'] github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-feeds-swift' @@ -117,7 +117,8 @@ lane :test do |options| devices: options[:device], number_of_retries: 3, skip_build: options[:skip_build], - build_for_testing: options[:build_for_testing] + build_for_testing: options[:build_for_testing], + parallel_testing: !options[:cron] } begin @@ -133,6 +134,28 @@ lane :test do |options| slather unless options[:build_for_testing] end +private_lane :retreive_failed_tests do + report_path = 'test_output/report.junit' + raise UI.user_error!('There is no junit report to parse') unless File.file?(report_path) + + junit_report = Nokogiri::XML(File.read(report_path)) + failed_tests = [] + passed_tests = [] + suite_name = junit_report.xpath('//testsuite').first['name'].split('.').first + junit_report.xpath('//testcase').each do |testcase| + class_name = testcase['classname'].split('.').last + test_name = testcase['name'].delete('()') + + if testcase.at_xpath('failure') + failed_tests << "#{suite_name}/#{class_name}/#{test_name}" + else + passed_tests << "#{suite_name}/#{class_name}/#{test_name}" + end + end + + (failed_tests - passed_tests).uniq +end + private_lane :update_testplan_on_ci do |options| update_testplan(path: options[:path], env_vars: { key: 'CI', value: 'TRUE' }) if is_ci || options[:force] end @@ -195,11 +218,9 @@ desc 'Run source code formatting/linting' lane :run_swift_format do |options| Dir.chdir('..') do strict = options[:strict] ? '--lint' : nil - sources_matrix[:swiftformat].each do |path| - sh("swiftformat #{strict} --config .swiftformat #{path}") - sh("swiftlint lint --config .swiftlint.yml --fix --progress --reporter json #{path}") unless strict - sh("swiftlint lint --config .swiftlint.yml --strict --progress --reporter json #{path}") - end + sh("swiftformat #{strict} --config .swiftformat .") + sh("swiftlint lint --config .swiftlint.yml --fix --progress --reporter json") unless strict + sh("swiftlint lint --config .swiftlint.yml --strict --progress --reporter json") end end @@ -213,8 +234,7 @@ lane :sources_matrix do sample_apps: ['Sources', 'DemoApp', xcode_project], ruby: ['fastlane', 'Gemfile', 'Gemfile.lock'], size: ['Sources', xcode_project], - public_interface: ['Sources'], - swiftformat: ['Sources', 'DemoApp', 'Tests', 'Package.swift'] + public_interface: ['Sources'] } end @@ -231,12 +251,15 @@ end lane :validate_public_interface do next unless is_check_required(sources: sources_matrix[:public_interface], force_check: @force_check) - # Run the analysis on the current branch + # Get branch names original_branch = current_branch + target_branch = ENV['GITHUB_BASE_REF'] || (original_branch.include?('release/') ? 'main' : 'develop') + UI.important("Target branch: #{target_branch} 🕊️") + + # Run the analysis on the current branch sh('interface-analyser analysis ../Sources/ public_interface_current.json') # Checkout the target branch - target_branch = original_branch.include?('release/') ? 'main' : 'develop' sh("git fetch origin #{target_branch}") sh("git checkout #{target_branch}") @@ -283,7 +306,7 @@ lane :update_img_shields_sdk_sizes do |options| ) end -def frameworks_sizes +private_lane :frameworks_sizes do root_dir = 'Build/SDKSize' archive_dir = "#{root_dir}/DemoApp.xcarchive" @@ -295,7 +318,9 @@ def frameworks_sizes scheme: 'DemoApp', archive_path: archive_dir, export_method: 'ad-hoc', - export_options: 'fastlane/sdk_size_export_options.plist' + export_options: 'fastlane/sdk_size_export_options.plist', + derived_data_path: derived_data_path, + cloned_source_packages_path: source_packages_path ) frameworks_path = "../#{archive_dir}/Products/Library/Frameworks/DemoApp.app/Frameworks" @@ -306,3 +331,20 @@ def frameworks_sizes StreamFeeds: stream_feeds_size_kb.round(0) } end + +lane :size_analyze do + next unless is_check_required(sources: sources_matrix[:size], force_check: @force_check) + + gym( + scheme: 'DemoApp', + configuration: 'Release', + skip_archive: true, + skip_package_ipa: true, + skip_package_pkg: true, + skip_codesigning: true, + derived_data_path: derived_data_path, + cloned_source_packages_path: source_packages_path + ) + + show_detailed_sdk_size(sdk_names: sdk_names, threshold: 42) +end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index 60e26e7..184bf55 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -3,4 +3,5 @@ # Ensure this file is checked in to source control! gem 'fastlane-plugin-versioning' -gem 'fastlane-plugin-stream_actions', '0.3.90' +gem 'fastlane-plugin-stream_actions', '0.3.101' +gem 'fastlane-plugin-xcsize', '1.2.0' diff --git a/fastlane/Scanfile b/fastlane/Scanfile index 2c94dca..adc7b4d 100644 --- a/fastlane/Scanfile +++ b/fastlane/Scanfile @@ -1,9 +1,2 @@ -code_coverage(true) - -disable_concurrent_testing(true) - -configuration("Debug") - +configuration('Debug') result_bundle(true) - -skip_slack(true) diff --git a/lefthook.yml b/lefthook.yml index 13e7a3f..61221d9 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,8 +4,6 @@ pre-commit: - run: swiftlint lint --config .swiftlint.yml --fix --progress --reporter json {staged_files} glob: "*.{swift}" stage_fixed: true - exclude: - - "**/generated/**" skip: - merge - rebase @@ -21,7 +19,5 @@ pre-push: jobs: - run: swiftlint lint --config .swiftlint.yml --strict --progress --reporter json {push_files} glob: "*.{swift}" - exclude: - - "**/generated/**" skip: - merge-commit