Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### 🔄 Changed
### ✅ Added
- Keep `FeedData.ownCapabilities` up to date in every model when handling web-socket events [#51](https://github.com/GetStream/stream-feeds-swift/pull/51)

# [0.4.0](https://github.com/GetStream/stream-feeds-swift/releases/tag/0.4.0)
_September 25, 2025_
Expand Down
2 changes: 1 addition & 1 deletion DemoApp/FeedsView/FeedsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ struct ActivityView: View {
@State var selectedAttachment: Attachment?

let user: UserData
let ownCapabilities: [FeedOwnCapability]
let ownCapabilities: Set<FeedOwnCapability>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For O(1) lookups

let text: String
var attachments: [Attachment]?
var activity: ActivityData
Expand Down
12 changes: 12 additions & 0 deletions Sources/StreamFeeds/Extensions/Dictionary+Extensions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
13 changes: 12 additions & 1 deletion Sources/StreamFeeds/FeedsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public final class FeedsClient: Sendable {
let feedsRepository: FeedsRepository
let moderationRepository: ModerationRepository
let pollsRepository: PollsRepository
let ownCapabilitiesRepository: OwnCapabilitiesRepository

private let _token: AllocatedUnfairLock<UserToken>
private let _userAuth = AllocatedUnfairLock<UserAuth?>(nil)
Expand Down Expand Up @@ -127,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
Expand Down
20 changes: 19 additions & 1 deletion Sources/StreamFeeds/Models/ActivityData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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?
Expand Down Expand Up @@ -137,6 +137,24 @@ extension ActivityData {
changes: { $0.merge(with: incomingData, update: reaction, currentUserId: currentUserId) }
)
}

// MARK: - Current Feed Capabilities

mutating func setFeedOwnCapabilities(_ capabilities: Set<FeedOwnCapability>) {
currentFeed?.setOwnCapabilities(capabilities)
}

mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set<FeedOwnCapability>]) {
guard let feedId = currentFeed?.feed else { return }
guard let capabilities = capabilitiesMap[feedId] else { return }
currentFeed?.setOwnCapabilities(capabilities)
}

func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set<FeedOwnCapability>]) -> ActivityData {
var updated = self
updated.mergeFeedOwnCapabilities(from: capabilitiesMap)
return updated
}
}

// MARK: - Model Conversions
Expand Down
22 changes: 21 additions & 1 deletion Sources/StreamFeeds/Models/BookmarkData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -20,6 +20,26 @@ extension BookmarkData: Identifiable {
}
}

// 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<FeedOwnCapability>]) {
activity.mergeFeedOwnCapabilities(from: capabilitiesMap)
}

func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set<FeedOwnCapability>]) -> BookmarkData {
var updated = self
updated.mergeFeedOwnCapabilities(from: capabilitiesMap)
return updated
}
}

// MARK: - Model Conversions

extension BookmarkResponse {
Expand Down
37 changes: 33 additions & 4 deletions Sources/StreamFeeds/Models/FeedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,43 @@ public struct FeedData: Identifiable, Equatable, Sendable {
public let id: String
public let memberCount: Int
public let name: String
public let ownCapabilities: [FeedOwnCapability]?
public let ownFollows: [FollowData]?
public let ownMembership: FeedMemberData?
public private(set) var ownCapabilities: Set<FeedOwnCapability>?
public private(set) var ownFollows: [FollowData]?
public private(set) var ownMembership: FeedMemberData?
public let pinCount: Int
public let updatedAt: Date
public let visibility: String?
}

// 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<FeedOwnCapability>) {
self.ownCapabilities = capabilities
}

mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set<FeedOwnCapability>]) {
guard let capabilities = capabilitiesMap[feed] else { return }
setOwnCapabilities(capabilities)
}

func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set<FeedOwnCapability>]) -> FeedData {
var updated = self
updated.mergeFeedOwnCapabilities(from: capabilitiesMap)
return updated
}
}

// MARK: - Model Conversions

extension FeedResponse {
Expand All @@ -45,7 +74,7 @@ extension FeedResponse {
id: id,
memberCount: memberCount,
name: name,
ownCapabilities: ownCapabilities,
ownCapabilities: ownCapabilities.map(Set.init),
ownFollows: ownFollows?.map { $0.toModel() },
ownMembership: ownMembership?.toModel(),
pinCount: pinCount,
Expand Down
25 changes: 23 additions & 2 deletions Sources/StreamFeeds/Models/FollowData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -46,6 +46,27 @@ extension FollowData: Identifiable {
}
}

// MARK: - Mutating the Data

extension FollowData {
// MARK: - Current Feed Capabilities

mutating func mergeFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set<FeedOwnCapability>]) {
if let capabilities = capabilitiesMap[sourceFeed.feed] {
sourceFeed.setOwnCapabilities(capabilities)
}
if let capabilities = capabilitiesMap[targetFeed.feed] {
targetFeed.setOwnCapabilities(capabilities)
}
}

func withFeedOwnCapabilities(from capabilitiesMap: [FeedId: Set<FeedOwnCapability>]) -> FollowData {
var updated = self
updated.mergeFeedOwnCapabilities(from: capabilitiesMap)
return updated
}
}

// MARK: - Model Conversions

extension FollowResponse {
Expand Down
40 changes: 29 additions & 11 deletions Sources/StreamFeeds/Repositories/FeedsRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivitiesSortField>.defaultSorting),
models: activities.sorted(using: Sort<ActivitiesSortField>.defaultSorting),
pagination: PaginationData(next: response.next, previous: response.prev)
),
activitiesQueryConfig: QueryConfiguration(
filter: query.activityFilter,
sort: Sort<ActivitiesSortField>.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
)
}

Expand Down Expand Up @@ -145,14 +162,15 @@ extension FeedsRepository {
struct GetOrCreateInfo {
let activities: PaginationResult<ActivityData>
let activitiesQueryConfig: QueryConfiguration<ActivitiesFilter, ActivitiesSortField>
let aggregatedActivities: [AggregatedActivityData]
let allOwnCapabilities: [FeedId: Set<FeedOwnCapability>]
let feed: FeedData
let followRequests: [FollowData]
let followers: [FollowData]
let following: [FollowData]
let followRequests: [FollowData]
let members: PaginationResult<FeedMemberData>
let ownCapabilities: [FeedOwnCapability]
let pinnedActivities: [ActivityPinData]
let aggregatedActivities: [AggregatedActivityData]
let notificationStatus: NotificationStatusData?
let ownCapabilities: Set<FeedOwnCapability>
let pinnedActivities: [ActivityPinData]
}
}
Original file line number Diff line number Diff line change
@@ -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<FeedOwnCapability>]())

init(apiClient: DefaultAPI) {
self.apiClient = apiClient
}

// MARK: - Get Capabilities

/// Returns locally cached capabilities if available.
func capabilities(for feed: FeedId) -> Set<FeedOwnCapability>? {
self.capabilities(for: Set(arrayLiteral: feed))?[feed]
}

/// Returns locally cached capabilities if available.
func capabilities(for feeds: Set<FeedId>) -> [FeedId: Set<FeedOwnCapability>]? {
let cached = storage.withLock { storage in
feeds.reduce(into: [FeedId: Set<FeedOwnCapability>](), { 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<FeedId>) async throws -> [FeedId: Set<FeedOwnCapability>] {
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<FeedOwnCapability>]) -> [FeedId: Set<FeedOwnCapability>]? {
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<FeedOwnCapability>]? {
guard !feedDatas.isEmpty else { return nil }
let all = feedDatas.reduce(into: [FeedId: Set<FeedOwnCapability>](), { 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<FeedOwnCapability>]? {
saveCapabilities(in: [feedData].compactMap { $0 })
}
Comment on lines +64 to +66
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Convenience mostly for ActivityData.currentFeed (optional)

}
5 changes: 5 additions & 0 deletions Sources/StreamFeeds/StateLayer/Activity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityState>
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Loading