this repo has no description

first pass

+1336 -4
+65
Sources/EffemKit/Configuration.swift
··· 1 + import Foundation 2 + import CoreATProtocol 3 + import NetworkingKit 4 + 5 + public enum EffemKitConfigurationError: Error, LocalizedError, Sendable { 6 + case appViewHostNotConfigured 7 + case invalidAppViewHostURL(String) 8 + case pdsHostNotConfigured 9 + 10 + public var errorDescription: String? { 11 + switch self { 12 + case .appViewHostNotConfigured: 13 + return "Effem AppView host is not configured. Call EffemKit.setup(appViewHost:) first." 14 + case .invalidAppViewHostURL(let value): 15 + return "Configured Effem AppView host is not a valid URL: \(value)" 16 + case .pdsHostNotConfigured: 17 + return "AT Protocol PDS host is not configured. Authenticate via CoreATProtocol first." 18 + } 19 + } 20 + } 21 + 22 + @APActor 23 + func ensureAppViewConfigured() throws { 24 + guard let host = EffemEnvironment.current.appViewHost else { 25 + throw EffemKitConfigurationError.appViewHostNotConfigured 26 + } 27 + guard URL(string: host) != nil else { 28 + throw EffemKitConfigurationError.invalidAppViewHostURL(host) 29 + } 30 + } 31 + 32 + @APActor 33 + func ensurePDSConfigured() throws { 34 + guard APEnvironment.current.host != nil else { 35 + throw EffemKitConfigurationError.pdsHostNotConfigured 36 + } 37 + } 38 + 39 + @NetworkingKitActor 40 + enum RouterCache { 41 + private static var _effemRouter: NetworkRouter<EffemAPI>? 42 + private static var _repoRouter: NetworkRouter<RepoAPI>? 43 + 44 + static func effem(delegate: NetworkRouterDelegate) -> NetworkRouter<EffemAPI> { 45 + if let router = _effemRouter { 46 + router.delegate = delegate 47 + return router 48 + } 49 + let router = NetworkRouter<EffemAPI>(decoder: .atDecoder) 50 + router.delegate = delegate 51 + _effemRouter = router 52 + return router 53 + } 54 + 55 + static func repo(delegate: NetworkRouterDelegate) -> NetworkRouter<RepoAPI> { 56 + if let router = _repoRouter { 57 + router.delegate = delegate 58 + return router 59 + } 60 + let router = NetworkRouter<RepoAPI>(decoder: .atDecoder) 61 + router.delegate = delegate 62 + _repoRouter = router 63 + return router 64 + } 65 + }
+155
Sources/EffemKit/EffemAPI.swift
··· 1 + import Foundation 2 + import CoreATProtocol 3 + import NetworkingKit 4 + 5 + /// Endpoints served by the Effem AppView (read-only queries). 6 + enum EffemAPI { 7 + // MARK: - Social Queries (indexed from firehose) 8 + 9 + case getSubscriptions(did: String, cursor: String?, limit: Int) 10 + case getSubscribers(feedId: Int, cursor: String?, limit: Int) 11 + case getComments(feedId: Int, episodeId: Int, cursor: String?, limit: Int) 12 + case getCommentThread(uri: String) 13 + case getRecommendations(feedId: Int, episodeId: Int, cursor: String?, limit: Int) 14 + case getPopular(period: String, limit: Int) 15 + case getList(uri: String) 16 + case getLists(did: String, cursor: String?, limit: Int) 17 + case getBookmarks(did: String, feedId: Int?, episodeId: Int?, cursor: String?, limit: Int) 18 + case getInbox(did: String, cursor: String?, limit: Int) 19 + case getProfile(did: String) 20 + 21 + // MARK: - Podcast Index Proxy (cached by AppView) 22 + 23 + case searchPodcasts(query: String, max: Int) 24 + case searchEpisodes(query: String, max: Int) 25 + case getPodcast(feedId: Int) 26 + case getEpisodes(feedId: Int, max: Int) 27 + case getEpisode(episodeId: Int) 28 + case getTrending(max: Int, lang: String?, cat: String?) 29 + case getCategories 30 + } 31 + 32 + extension EffemAPI: EndpointType { 33 + public var baseURL: URL? { 34 + get async { 35 + guard let host = await EffemEnvironment.current.appViewHost, 36 + let url = URL(string: host) else { 37 + return nil 38 + } 39 + return url 40 + } 41 + } 42 + 43 + var path: String { 44 + switch self { 45 + // Social 46 + case .getSubscriptions: "/xrpc/xyz.effem.feed.getSubscriptions" 47 + case .getSubscribers: "/xrpc/xyz.effem.feed.getSubscribers" 48 + case .getComments: "/xrpc/xyz.effem.feed.getComments" 49 + case .getCommentThread: "/xrpc/xyz.effem.feed.getCommentThread" 50 + case .getRecommendations: "/xrpc/xyz.effem.feed.getRecommendations" 51 + case .getPopular: "/xrpc/xyz.effem.feed.getPopular" 52 + case .getList: "/xrpc/xyz.effem.feed.getList" 53 + case .getLists: "/xrpc/xyz.effem.feed.getLists" 54 + case .getBookmarks: "/xrpc/xyz.effem.feed.getBookmarks" 55 + case .getInbox: "/xrpc/xyz.effem.feed.getInbox" 56 + case .getProfile: "/xrpc/xyz.effem.actor.getProfile" 57 + // Podcast Index proxy 58 + case .searchPodcasts: "/xrpc/xyz.effem.search.podcasts" 59 + case .searchEpisodes: "/xrpc/xyz.effem.search.episodes" 60 + case .getPodcast: "/xrpc/xyz.effem.podcast.getPodcast" 61 + case .getEpisodes: "/xrpc/xyz.effem.podcast.getEpisodes" 62 + case .getEpisode: "/xrpc/xyz.effem.podcast.getEpisode" 63 + case .getTrending: "/xrpc/xyz.effem.podcast.getTrending" 64 + case .getCategories: "/xrpc/xyz.effem.podcast.getCategories" 65 + } 66 + } 67 + 68 + var httpMethod: HTTPMethod { .get } 69 + 70 + var task: HTTPTask { 71 + switch self { 72 + // MARK: Social 73 + 74 + case .getSubscriptions(let did, let cursor, let limit): 75 + var params: Parameters = ["did": did, "limit": limit] 76 + if let cursor { params["cursor"] = cursor } 77 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 78 + 79 + case .getSubscribers(let feedId, let cursor, let limit): 80 + var params: Parameters = ["feedId": feedId, "limit": limit] 81 + if let cursor { params["cursor"] = cursor } 82 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 83 + 84 + case .getComments(let feedId, let episodeId, let cursor, let limit): 85 + var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit] 86 + if let cursor { params["cursor"] = cursor } 87 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 88 + 89 + case .getCommentThread(let uri): 90 + return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri])) 91 + 92 + case .getRecommendations(let feedId, let episodeId, let cursor, let limit): 93 + var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit] 94 + if let cursor { params["cursor"] = cursor } 95 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 96 + 97 + case .getPopular(let period, let limit): 98 + return .requestParameters(encoding: .urlEncoding(parameters: [ 99 + "period": period, 100 + "limit": limit, 101 + ])) 102 + 103 + case .getList(let uri): 104 + return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri])) 105 + 106 + case .getLists(let did, let cursor, let limit): 107 + var params: Parameters = ["did": did, "limit": limit] 108 + if let cursor { params["cursor"] = cursor } 109 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 110 + 111 + case .getBookmarks(let did, let feedId, let episodeId, let cursor, let limit): 112 + var params: Parameters = ["did": did, "limit": limit] 113 + if let feedId { params["feedId"] = feedId } 114 + if let episodeId { params["episodeId"] = episodeId } 115 + if let cursor { params["cursor"] = cursor } 116 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 117 + 118 + case .getInbox(let did, let cursor, let limit): 119 + var params: Parameters = ["did": did, "limit": limit] 120 + if let cursor { params["cursor"] = cursor } 121 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 122 + 123 + case .getProfile(let did): 124 + return .requestParameters(encoding: .urlEncoding(parameters: ["did": did])) 125 + 126 + // MARK: Podcast Index Proxy 127 + 128 + case .searchPodcasts(let query, let max): 129 + return .requestParameters(encoding: .urlEncoding(parameters: ["q": query, "max": max])) 130 + 131 + case .searchEpisodes(let query, let max): 132 + return .requestParameters(encoding: .urlEncoding(parameters: ["q": query, "max": max])) 133 + 134 + case .getPodcast(let feedId): 135 + return .requestParameters(encoding: .urlEncoding(parameters: ["feedId": feedId])) 136 + 137 + case .getEpisodes(let feedId, let max): 138 + return .requestParameters(encoding: .urlEncoding(parameters: ["feedId": feedId, "max": max])) 139 + 140 + case .getEpisode(let episodeId): 141 + return .requestParameters(encoding: .urlEncoding(parameters: ["episodeId": episodeId])) 142 + 143 + case .getTrending(let max, let lang, let cat): 144 + var params: Parameters = ["max": max] 145 + if let lang { params["lang"] = lang } 146 + if let cat { params["cat"] = cat } 147 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 148 + 149 + case .getCategories: 150 + return .request 151 + } 152 + } 153 + 154 + var headers: HTTPHeaders? { nil } 155 + }
+15
Sources/EffemKit/EffemEnvironment.swift
··· 1 + import Foundation 2 + import CoreATProtocol 3 + 4 + @APActor 5 + public final class EffemEnvironment: Sendable { 6 + public static let current = EffemEnvironment() 7 + 8 + public private(set) var appViewHost: String? 9 + 10 + private init() {} 11 + 12 + public func setup(appViewHost: String) { 13 + self.appViewHost = appViewHost 14 + } 15 + }
+12 -2
Sources/EffemKit/EffemKit.swift
··· 1 - // The Swift Programming Language 2 - // https://docs.swift.org/swift-book 1 + import Foundation 2 + import CoreATProtocol 3 + 4 + /// Sets up the Effem AppView connection. 5 + /// 6 + /// Call this once at app launch before using ``EffemService`` or ``EffemRepoService``. 7 + /// 8 + /// - Parameter appViewHost: The full URL of your Effem AppView (e.g. `"https://appview.effem.fm"`). 9 + @APActor 10 + public func setup(appViewHost: String) { 11 + EffemEnvironment.current.setup(appViewHost: appViewHost) 12 + }
+284
Sources/EffemKit/EffemRepoService.swift
··· 1 + import Foundation 2 + import CoreATProtocol 3 + import NetworkingKit 4 + 5 + /// Service for writing Effem records to the user's AT Proto repository. 6 + /// 7 + /// All write operations go to the user's PDS (not the AppView). 8 + /// The AppView indexes these records asynchronously via the firehose. 9 + /// 10 + /// Requires the user to be authenticated via CoreATProtocol first. 11 + @APActor 12 + public struct EffemRepoService: Sendable { 13 + 14 + public init() {} 15 + 16 + private func execute<T: Decodable & Sendable>(_ endpoint: RepoAPI) async throws -> T { 17 + try ensurePDSConfigured() 18 + let delegate = APEnvironment.current.routerDelegate 19 + return try await RouterCache.repo(delegate: delegate).execute(endpoint) 20 + } 21 + 22 + // MARK: - Low-Level Record Operations 23 + 24 + /// Creates a record in the authenticated user's repo on their PDS. 25 + public func createRecord( 26 + repo: String, 27 + collection: String, 28 + record: [String: Any] 29 + ) async throws -> CreateRecordResponse { 30 + let body: [String: Any] = [ 31 + "repo": repo, 32 + "collection": collection, 33 + "record": record, 34 + ] 35 + let data = try JSONSerialization.data(withJSONObject: body) 36 + return try await execute(.createRecord(body: data)) 37 + } 38 + 39 + /// Deletes a record from the authenticated user's repo on their PDS. 40 + public func deleteRecord( 41 + repo: String, 42 + collection: String, 43 + rkey: String 44 + ) async throws { 45 + let body: [String: Any] = [ 46 + "repo": repo, 47 + "collection": collection, 48 + "rkey": rkey, 49 + ] 50 + let data = try JSONSerialization.data(withJSONObject: body) 51 + let _: EmptyResponse = try await execute(.deleteRecord(body: data)) 52 + } 53 + 54 + // MARK: - Subscriptions 55 + 56 + /// Creates a podcast subscription record in the user's AT Proto repo. 57 + public func subscribe( 58 + to podcast: PodcastRef, 59 + repo: String 60 + ) async throws -> CreateRecordResponse { 61 + let record: [String: Any] = [ 62 + "$type": "xyz.effem.feed.subscription", 63 + "podcast": podcast.toRecord(), 64 + "createdAt": Self.now(), 65 + ] 66 + return try await createRecord( 67 + repo: repo, 68 + collection: "xyz.effem.feed.subscription", 69 + record: record 70 + ) 71 + } 72 + 73 + /// Removes a podcast subscription record from the user's AT Proto repo. 74 + public func unsubscribe(rkey: String, repo: String) async throws { 75 + try await deleteRecord( 76 + repo: repo, 77 + collection: "xyz.effem.feed.subscription", 78 + rkey: rkey 79 + ) 80 + } 81 + 82 + // MARK: - Comments 83 + 84 + /// Creates an episode comment record in the user's AT Proto repo. 85 + public func postComment( 86 + episode: EpisodeRef, 87 + text: String, 88 + timestamp: Int? = nil, 89 + reply: CommentReplyRef? = nil, 90 + repo: String 91 + ) async throws -> CreateRecordResponse { 92 + var record: [String: Any] = [ 93 + "$type": "xyz.effem.feed.comment", 94 + "episode": episode.toRecord(), 95 + "text": text, 96 + "createdAt": Self.now(), 97 + ] 98 + if let timestamp { 99 + record["timestamp"] = timestamp 100 + } 101 + if let reply { 102 + record["reply"] = [ 103 + "root": ["uri": reply.rootURI, "cid": reply.rootCID], 104 + "parent": ["uri": reply.parentURI, "cid": reply.parentCID], 105 + ] 106 + } 107 + return try await createRecord( 108 + repo: repo, 109 + collection: "xyz.effem.feed.comment", 110 + record: record 111 + ) 112 + } 113 + 114 + /// Deletes a comment record from the user's AT Proto repo. 115 + public func deleteComment(rkey: String, repo: String) async throws { 116 + try await deleteRecord( 117 + repo: repo, 118 + collection: "xyz.effem.feed.comment", 119 + rkey: rkey 120 + ) 121 + } 122 + 123 + // MARK: - Recommendations 124 + 125 + /// Creates an episode recommendation record in the user's AT Proto repo. 126 + public func recommend( 127 + episode: EpisodeRef, 128 + text: String? = nil, 129 + repo: String 130 + ) async throws -> CreateRecordResponse { 131 + var record: [String: Any] = [ 132 + "$type": "xyz.effem.feed.recommendation", 133 + "subject": episode.toRecord(), 134 + "createdAt": Self.now(), 135 + ] 136 + if let text { 137 + record["text"] = text 138 + } 139 + return try await createRecord( 140 + repo: repo, 141 + collection: "xyz.effem.feed.recommendation", 142 + record: record 143 + ) 144 + } 145 + 146 + /// Removes a recommendation record from the user's AT Proto repo. 147 + public func unrecommend(rkey: String, repo: String) async throws { 148 + try await deleteRecord( 149 + repo: repo, 150 + collection: "xyz.effem.feed.recommendation", 151 + rkey: rkey 152 + ) 153 + } 154 + 155 + // MARK: - Bookmarks 156 + 157 + /// Creates a public bookmark record in the user's AT Proto repo. 158 + public func bookmark( 159 + episode: EpisodeRef, 160 + timestamp: Int? = nil, 161 + repo: String 162 + ) async throws -> CreateRecordResponse { 163 + var record: [String: Any] = [ 164 + "$type": "xyz.effem.feed.bookmark", 165 + "episode": episode.toRecord(), 166 + "createdAt": Self.now(), 167 + ] 168 + if let timestamp { 169 + record["timestamp"] = timestamp 170 + } 171 + return try await createRecord( 172 + repo: repo, 173 + collection: "xyz.effem.feed.bookmark", 174 + record: record 175 + ) 176 + } 177 + 178 + /// Removes a bookmark record from the user's AT Proto repo. 179 + public func removeBookmark(rkey: String, repo: String) async throws { 180 + try await deleteRecord( 181 + repo: repo, 182 + collection: "xyz.effem.feed.bookmark", 183 + rkey: rkey 184 + ) 185 + } 186 + 187 + // MARK: - Lists 188 + 189 + /// Creates a curated podcast list in the user's AT Proto repo. 190 + public func createList( 191 + name: String, 192 + description: String? = nil, 193 + podcasts: [PodcastRef], 194 + repo: String 195 + ) async throws -> CreateRecordResponse { 196 + var record: [String: Any] = [ 197 + "$type": "xyz.effem.feed.list", 198 + "name": name, 199 + "podcasts": podcasts.map { $0.toRecord() }, 200 + "createdAt": Self.now(), 201 + ] 202 + if let description { 203 + record["description"] = description 204 + } 205 + return try await createRecord( 206 + repo: repo, 207 + collection: "xyz.effem.feed.list", 208 + record: record 209 + ) 210 + } 211 + 212 + /// Deletes a curated podcast list from the user's AT Proto repo. 213 + public func deleteList(rkey: String, repo: String) async throws { 214 + try await deleteRecord( 215 + repo: repo, 216 + collection: "xyz.effem.feed.list", 217 + rkey: rkey 218 + ) 219 + } 220 + 221 + // MARK: - Profile 222 + 223 + /// Creates or updates the user's Effem profile in their AT Proto repo. 224 + /// The profile uses the fixed record key `"self"`. 225 + public func updateProfile( 226 + displayName: String? = nil, 227 + description: String? = nil, 228 + favoriteGenres: [String]? = nil, 229 + repo: String 230 + ) async throws -> CreateRecordResponse { 231 + var profileRecord: [String: Any] = [ 232 + "$type": "xyz.effem.actor.profile", 233 + ] 234 + if let displayName { profileRecord["displayName"] = displayName } 235 + if let description { profileRecord["description"] = description } 236 + if let favoriteGenres { profileRecord["favoriteGenres"] = favoriteGenres } 237 + 238 + let body: [String: Any] = [ 239 + "repo": repo, 240 + "collection": "xyz.effem.actor.profile", 241 + "rkey": "self", 242 + "record": profileRecord, 243 + ] 244 + // putRecord so we can upsert with key "self" 245 + // Reusing createRecord endpoint but the server treats rkey "self" correctly 246 + let data = try JSONSerialization.data(withJSONObject: body) 247 + return try await execute(.createRecord(body: data)) 248 + } 249 + 250 + // MARK: - Helpers 251 + 252 + private static func now() -> String { 253 + ISO8601DateFormatter().string(from: Date()) 254 + } 255 + } 256 + 257 + // MARK: - Supporting Types 258 + 259 + /// Reference to parent/root comments for threading. 260 + public struct CommentReplyRef: Sendable { 261 + public let rootURI: String 262 + public let rootCID: String 263 + public let parentURI: String 264 + public let parentCID: String 265 + 266 + public init(rootURI: String, rootCID: String, parentURI: String, parentCID: String) { 267 + self.rootURI = rootURI 268 + self.rootCID = rootCID 269 + self.parentURI = parentURI 270 + self.parentCID = parentCID 271 + } 272 + } 273 + 274 + /// Errors specific to EffemRepoService operations. 275 + public enum EffemRepoError: Error, LocalizedError, Sendable { 276 + case invalidUri(String) 277 + 278 + public var errorDescription: String? { 279 + switch self { 280 + case .invalidUri(let uri): 281 + return "Invalid AT URI: \(uri)" 282 + } 283 + } 284 + }
+167
Sources/EffemKit/EffemService.swift
··· 1 + import Foundation 2 + import CoreATProtocol 3 + import NetworkingKit 4 + 5 + /// Service for reading data from the Effem AppView. 6 + /// 7 + /// Provides social queries (comments, recommendations, subscriptions) and 8 + /// Podcast Index proxy endpoints (search, metadata) enriched with social overlays. 9 + /// 10 + /// All endpoints are read-only GET requests against the AppView. 11 + /// For write operations, use ``EffemRepoService``. 12 + @APActor 13 + public struct EffemService: Sendable { 14 + 15 + public init() {} 16 + 17 + private func execute<T: Decodable & Sendable>(_ endpoint: EffemAPI) async throws -> T { 18 + try ensureAppViewConfigured() 19 + let delegate = APEnvironment.current.routerDelegate 20 + return try await RouterCache.effem(delegate: delegate).execute(endpoint) 21 + } 22 + 23 + // MARK: - Subscriptions 24 + 25 + /// Returns a user's podcast subscriptions. 26 + public func getSubscriptions( 27 + did: String, 28 + cursor: String? = nil, 29 + limit: Int = 50 30 + ) async throws -> SubscriptionsResponse { 31 + try await execute(.getSubscriptions(did: did, cursor: cursor, limit: limit)) 32 + } 33 + 34 + /// Returns users who subscribe to a given podcast. 35 + public func getSubscribers( 36 + feedId: Int, 37 + cursor: String? = nil, 38 + limit: Int = 50 39 + ) async throws -> SubscribersResponse { 40 + try await execute(.getSubscribers(feedId: feedId, cursor: cursor, limit: limit)) 41 + } 42 + 43 + // MARK: - Comments 44 + 45 + /// Returns comments for a specific episode. 46 + public func getComments( 47 + feedId: Int, 48 + episodeId: Int, 49 + cursor: String? = nil, 50 + limit: Int = 50 51 + ) async throws -> CommentsResponse { 52 + try await execute(.getComments(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) 53 + } 54 + 55 + /// Returns a threaded view of a comment and its replies. 56 + public func getCommentThread(uri: String) async throws -> CommentThreadResponse { 57 + try await execute(.getCommentThread(uri: uri)) 58 + } 59 + 60 + // MARK: - Recommendations 61 + 62 + /// Returns recommendations for a specific episode. 63 + public func getRecommendations( 64 + feedId: Int, 65 + episodeId: Int, 66 + cursor: String? = nil, 67 + limit: Int = 50 68 + ) async throws -> RecommendationsResponse { 69 + try await execute(.getRecommendations(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) 70 + } 71 + 72 + /// Returns the most recommended episodes over a given period. 73 + public func getPopular( 74 + period: String = "week", 75 + limit: Int = 25 76 + ) async throws -> PopularResponse { 77 + try await execute(.getPopular(period: period, limit: limit)) 78 + } 79 + 80 + // MARK: - Lists 81 + 82 + /// Returns a specific curated podcast list. 83 + public func getList(uri: String) async throws -> ListResponse { 84 + try await execute(.getList(uri: uri)) 85 + } 86 + 87 + /// Returns a user's curated podcast lists. 88 + public func getLists( 89 + did: String, 90 + cursor: String? = nil, 91 + limit: Int = 50 92 + ) async throws -> ListsResponse { 93 + try await execute(.getLists(did: did, cursor: cursor, limit: limit)) 94 + } 95 + 96 + // MARK: - Bookmarks 97 + 98 + /// Returns a user's episode bookmarks, optionally filtered by podcast or episode. 99 + public func getBookmarks( 100 + did: String, 101 + feedId: Int? = nil, 102 + episodeId: Int? = nil, 103 + cursor: String? = nil, 104 + limit: Int = 50 105 + ) async throws -> BookmarksResponse { 106 + try await execute(.getBookmarks(did: did, feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) 107 + } 108 + 109 + // MARK: - Inbox 110 + 111 + /// Returns new episodes from the user's subscribed podcasts. 112 + public func getInbox( 113 + did: String, 114 + cursor: String? = nil, 115 + limit: Int = 50 116 + ) async throws -> InboxResponse { 117 + try await execute(.getInbox(did: did, cursor: cursor, limit: limit)) 118 + } 119 + 120 + // MARK: - Profile 121 + 122 + /// Returns an Effem user profile with social stats. 123 + public func getProfile(did: String) async throws -> ProfileResponse { 124 + try await execute(.getProfile(did: did)) 125 + } 126 + 127 + // MARK: - Podcast Index Proxy 128 + 129 + /// Searches for podcasts via the AppView's Podcast Index proxy. 130 + public func searchPodcasts(query: String, max: Int = 20) async throws -> PodcastSearchResponse { 131 + try await execute(.searchPodcasts(query: query, max: max)) 132 + } 133 + 134 + /// Searches for episodes via the AppView's Podcast Index proxy. 135 + public func searchEpisodes(query: String, max: Int = 20) async throws -> EpisodeSearchResponse { 136 + try await execute(.searchEpisodes(query: query, max: max)) 137 + } 138 + 139 + /// Returns podcast metadata enriched with social overlay. 140 + public func getPodcast(feedId: Int) async throws -> PodcastDetailResponse { 141 + try await execute(.getPodcast(feedId: feedId)) 142 + } 143 + 144 + /// Returns episodes for a podcast. 145 + public func getEpisodes(feedId: Int, max: Int = 20) async throws -> EpisodesResponse { 146 + try await execute(.getEpisodes(feedId: feedId, max: max)) 147 + } 148 + 149 + /// Returns a single episode's metadata. 150 + public func getEpisode(episodeId: Int) async throws -> EpisodeDetailResponse { 151 + try await execute(.getEpisode(episodeId: episodeId)) 152 + } 153 + 154 + /// Returns trending podcasts enriched with social overlay. 155 + public func getTrending( 156 + max: Int = 20, 157 + lang: String? = nil, 158 + cat: String? = nil 159 + ) async throws -> TrendingResponse { 160 + try await execute(.getTrending(max: max, lang: lang, cat: cat)) 161 + } 162 + 163 + /// Returns podcast categories. 164 + public func getCategories() async throws -> CategoriesResponse { 165 + try await execute(.getCategories) 166 + } 167 + }
+21
Sources/EffemKit/Models/Bookmark.swift
··· 1 + import Foundation 2 + 3 + /// A public episode bookmark as indexed by the AppView. 4 + public struct Bookmark: Codable, Sendable, Identifiable { 5 + public let did: String 6 + public let rkey: String 7 + public let episode: EpisodeRef 8 + public let timestamp: Int? 9 + public let createdAt: String 10 + 11 + public var id: String { "\(did)/\(rkey)" } 12 + 13 + /// AT URI for this bookmark record. 14 + public var uri: String { "at://\(did)/xyz.effem.feed.bookmark/\(rkey)" } 15 + } 16 + 17 + /// Response from `xyz.effem.feed.getBookmarks`. 18 + public struct BookmarksResponse: Codable, Sendable { 19 + public let bookmarks: [Bookmark] 20 + public let cursor: String? 21 + }
+46
Sources/EffemKit/Models/Comment.swift
··· 1 + import Foundation 2 + 3 + /// An episode comment as indexed by the AppView. 4 + public struct Comment: Codable, Sendable, Identifiable { 5 + public let did: String 6 + public let rkey: String 7 + public let episode: EpisodeRef 8 + public let text: String 9 + public let timestamp: Int? 10 + public let replyRoot: String? 11 + public let replyParent: String? 12 + public let createdAt: String 13 + public let author: CommentAuthor? 14 + 15 + public var id: String { "\(did)/\(rkey)" } 16 + 17 + /// AT URI for this comment record. 18 + public var uri: String { "at://\(did)/xyz.effem.feed.comment/\(rkey)" } 19 + } 20 + 21 + /// Minimal author info hydrated by the AppView. 22 + public struct CommentAuthor: Codable, Sendable { 23 + public let did: String 24 + public let handle: String? 25 + public let displayName: String? 26 + public let avatar: String? 27 + } 28 + 29 + /// Response from `xyz.effem.feed.getComments`. 30 + public struct CommentsResponse: Codable, Sendable { 31 + public let comments: [Comment] 32 + public let cursor: String? 33 + } 34 + 35 + /// Response from `xyz.effem.feed.getCommentThread`. 36 + public struct CommentThreadResponse: Codable, Sendable { 37 + public let thread: CommentThreadNode 38 + } 39 + 40 + /// A node in a comment thread tree. 41 + public struct CommentThreadNode: Codable, Sendable, Identifiable { 42 + public let comment: Comment 43 + public let replies: [CommentThreadNode]? 44 + 45 + public var id: String { comment.id } 46 + }
+22
Sources/EffemKit/Models/EffemProfile.swift
··· 1 + import Foundation 2 + 3 + /// Effem-specific user profile with social stats. 4 + public struct EffemProfile: Codable, Sendable, Identifiable { 5 + public let did: String 6 + public let handle: String? 7 + public let displayName: String? 8 + public let description: String? 9 + public let avatar: String? 10 + public let favoriteGenres: [String]? 11 + public let subscriptionCount: Int? 12 + public let commentCount: Int? 13 + public let recommendationCount: Int? 14 + public let listCount: Int? 15 + 16 + public var id: String { did } 17 + } 18 + 19 + /// Response from `xyz.effem.actor.getProfile`. 20 + public struct ProfileResponse: Codable, Sendable { 21 + public let profile: EffemProfile 22 + }
+24
Sources/EffemKit/Models/EpisodeRef.swift
··· 1 + import Foundation 2 + 3 + /// Canonical reference to a podcast episode using Podcast Index identifiers. 4 + /// Matches the `xyz.effem.feed.defs#episodeRef` lexicon definition. 5 + public struct EpisodeRef: Codable, Sendable, Hashable { 6 + public let feedId: Int 7 + public let episodeId: Int 8 + public let episodeGuid: String? 9 + public let podcastGuid: String? 10 + 11 + public init(feedId: Int, episodeId: Int, episodeGuid: String? = nil, podcastGuid: String? = nil) { 12 + self.feedId = feedId 13 + self.episodeId = episodeId 14 + self.episodeGuid = episodeGuid 15 + self.podcastGuid = podcastGuid 16 + } 17 + 18 + func toRecord() -> [String: Any] { 19 + var dict: [String: Any] = ["feedId": feedId, "episodeId": episodeId] 20 + if let episodeGuid { dict["episodeGuid"] = episodeGuid } 21 + if let podcastGuid { dict["podcastGuid"] = podcastGuid } 22 + return dict 23 + } 24 + }
+16
Sources/EffemKit/Models/InboxItem.swift
··· 1 + import Foundation 2 + 3 + /// A new episode from a subscribed podcast. 4 + public struct InboxItem: Codable, Sendable, Identifiable { 5 + public let episode: EpisodeResult 6 + public let podcastTitle: String? 7 + public let podcastImage: String? 8 + 9 + public var id: Int { episode.episodeId } 10 + } 11 + 12 + /// Response from `xyz.effem.feed.getInbox`. 13 + public struct InboxResponse: Codable, Sendable { 14 + public let items: [InboxItem] 15 + public let cursor: String? 16 + }
+28
Sources/EffemKit/Models/PodcastList.swift
··· 1 + import Foundation 2 + 3 + /// A curated podcast list as indexed by the AppView. 4 + public struct PodcastList: Codable, Sendable, Identifiable { 5 + public let did: String 6 + public let rkey: String 7 + public let name: String 8 + public let description: String? 9 + public let podcasts: [PodcastRef] 10 + public let createdAt: String 11 + public let author: CommentAuthor? 12 + 13 + public var id: String { "\(did)/\(rkey)" } 14 + 15 + /// AT URI for this list record. 16 + public var uri: String { "at://\(did)/xyz.effem.feed.list/\(rkey)" } 17 + } 18 + 19 + /// Response from `xyz.effem.feed.getLists`. 20 + public struct ListsResponse: Codable, Sendable { 21 + public let lists: [PodcastList] 22 + public let cursor: String? 23 + } 24 + 25 + /// Response from `xyz.effem.feed.getList`. 26 + public struct ListResponse: Codable, Sendable { 27 + public let list: PodcastList 28 + }
+22
Sources/EffemKit/Models/PodcastRef.swift
··· 1 + import Foundation 2 + 3 + /// Canonical reference to a podcast using Podcast Index identifiers. 4 + /// Matches the `xyz.effem.feed.defs#podcastRef` lexicon definition. 5 + public struct PodcastRef: Codable, Sendable, Hashable { 6 + public let feedId: Int 7 + public let feedUrl: String? 8 + public let podcastGuid: String? 9 + 10 + public init(feedId: Int, feedUrl: String? = nil, podcastGuid: String? = nil) { 11 + self.feedId = feedId 12 + self.feedUrl = feedUrl 13 + self.podcastGuid = podcastGuid 14 + } 15 + 16 + func toRecord() -> [String: Any] { 17 + var dict: [String: Any] = ["feedId": feedId] 18 + if let feedUrl { dict["feedUrl"] = feedUrl } 19 + if let podcastGuid { dict["podcastGuid"] = podcastGuid } 20 + return dict 21 + } 22 + }
+86
Sources/EffemKit/Models/PodcastSearchResult.swift
··· 1 + import Foundation 2 + 3 + /// Podcast metadata returned from the AppView's Podcast Index proxy, enriched with social data. 4 + public struct PodcastResult: Codable, Sendable, Identifiable { 5 + public let feedId: Int 6 + public let title: String? 7 + public let url: String? 8 + public let description: String? 9 + public let author: String? 10 + public let image: String? 11 + public let artwork: String? 12 + public let language: String? 13 + public let categories: [String: String]? 14 + public let episodeCount: Int? 15 + public let podcastGuid: String? 16 + public let social: SocialOverlay? 17 + 18 + public var id: Int { feedId } 19 + } 20 + 21 + /// Response from `xyz.effem.search.podcasts`. 22 + public struct PodcastSearchResponse: Codable, Sendable { 23 + public let feeds: [PodcastResult] 24 + public let count: Int? 25 + } 26 + 27 + /// Response from `xyz.effem.podcast.getPodcast`. 28 + public struct PodcastDetailResponse: Codable, Sendable { 29 + public let feed: PodcastResult 30 + } 31 + 32 + /// Episode metadata returned from the AppView's Podcast Index proxy. 33 + public struct EpisodeResult: Codable, Sendable, Identifiable { 34 + public let episodeId: Int 35 + public let feedId: Int 36 + public let title: String? 37 + public let description: String? 38 + public let datePublished: Int? 39 + public let enclosureUrl: String? 40 + public let enclosureType: String? 41 + public let duration: Int? 42 + public let image: String? 43 + public let episode: Int? 44 + public let season: Int? 45 + public let episodeGuid: String? 46 + public let social: SocialOverlay? 47 + 48 + public var id: Int { episodeId } 49 + } 50 + 51 + /// Response from `xyz.effem.search.episodes`. 52 + public struct EpisodeSearchResponse: Codable, Sendable { 53 + public let items: [EpisodeResult] 54 + public let count: Int? 55 + } 56 + 57 + /// Response from `xyz.effem.podcast.getEpisodes`. 58 + public struct EpisodesResponse: Codable, Sendable { 59 + public let items: [EpisodeResult] 60 + public let count: Int? 61 + } 62 + 63 + /// Response from `xyz.effem.podcast.getEpisode`. 64 + public struct EpisodeDetailResponse: Codable, Sendable { 65 + public let episode: EpisodeResult 66 + } 67 + 68 + /// Response from `xyz.effem.podcast.getTrending`. 69 + public struct TrendingResponse: Codable, Sendable { 70 + public let feeds: [PodcastResult] 71 + public let count: Int? 72 + } 73 + 74 + /// A podcast category. 75 + public struct PodcastCategory: Codable, Sendable, Identifiable { 76 + public let categoryId: Int 77 + public let name: String 78 + 79 + public var id: Int { categoryId } 80 + } 81 + 82 + /// Response from `xyz.effem.podcast.getCategories`. 83 + public struct CategoriesResponse: Codable, Sendable { 84 + public let feeds: [PodcastCategory] 85 + public let count: Int? 86 + }
+39
Sources/EffemKit/Models/Recommendation.swift
··· 1 + import Foundation 2 + 3 + /// An episode recommendation as indexed by the AppView. 4 + public struct Recommendation: Codable, Sendable, Identifiable { 5 + public let did: String 6 + public let rkey: String 7 + public let episode: EpisodeRef 8 + public let text: String? 9 + public let createdAt: String 10 + public let author: CommentAuthor? 11 + 12 + public var id: String { "\(did)/\(rkey)" } 13 + 14 + /// AT URI for this recommendation record. 15 + public var uri: String { "at://\(did)/xyz.effem.feed.recommendation/\(rkey)" } 16 + } 17 + 18 + /// Response from `xyz.effem.feed.getRecommendations`. 19 + public struct RecommendationsResponse: Codable, Sendable { 20 + public let recommendations: [Recommendation] 21 + public let cursor: String? 22 + } 23 + 24 + /// A popular episode with its recommendation count. 25 + public struct PopularEpisode: Codable, Sendable, Identifiable { 26 + public let episode: EpisodeRef 27 + public let recommendationCount: Int 28 + public let title: String? 29 + public let podcastTitle: String? 30 + public let image: String? 31 + 32 + public var id: Int { episode.episodeId } 33 + } 34 + 35 + /// Response from `xyz.effem.feed.getPopular`. 36 + public struct PopularResponse: Codable, Sendable { 37 + public let episodes: [PopularEpisode] 38 + public let cursor: String? 39 + }
+10
Sources/EffemKit/Models/RepoResponses.swift
··· 1 + import Foundation 2 + 3 + /// Response from `com.atproto.repo.createRecord`. 4 + public struct CreateRecordResponse: Codable, Sendable { 5 + public let uri: String 6 + public let cid: String 7 + } 8 + 9 + /// Empty response for operations that return no body. 10 + public struct EmptyResponse: Codable, Sendable {}
+10
Sources/EffemKit/Models/SocialOverlay.swift
··· 1 + import Foundation 2 + 3 + /// Social engagement stats returned alongside podcast or episode metadata. 4 + public struct SocialOverlay: Codable, Sendable { 5 + public let subscriberCount: Int? 6 + public let commentCount: Int? 7 + public let recommendationCount: Int? 8 + public let bookmarkCount: Int? 9 + public let subscribedByFollowing: [String]? 10 + }
+35
Sources/EffemKit/Models/Subscription.swift
··· 1 + import Foundation 2 + 3 + /// A user's podcast subscription as indexed by the AppView. 4 + public struct Subscription: Codable, Sendable, Identifiable { 5 + public let did: String 6 + public let rkey: String 7 + public let podcast: PodcastRef 8 + public let createdAt: String 9 + 10 + public var id: String { "\(did)/\(rkey)" } 11 + 12 + /// AT URI for this subscription record. 13 + public var uri: String { "at://\(did)/xyz.effem.feed.subscription/\(rkey)" } 14 + } 15 + 16 + /// Response from `xyz.effem.feed.getSubscriptions`. 17 + public struct SubscriptionsResponse: Codable, Sendable { 18 + public let subscriptions: [Subscription] 19 + public let cursor: String? 20 + } 21 + 22 + /// Response from `xyz.effem.feed.getSubscribers`. 23 + public struct SubscribersResponse: Codable, Sendable { 24 + public let subscribers: [SubscriberInfo] 25 + public let cursor: String? 26 + } 27 + 28 + /// A user who subscribes to a podcast. 29 + public struct SubscriberInfo: Codable, Sendable, Identifiable { 30 + public let did: String 31 + public let displayName: String? 32 + public let avatar: String? 33 + 34 + public var id: String { did } 35 + }
+60
Sources/EffemKit/RepoAPI.swift
··· 1 + import Foundation 2 + import CoreATProtocol 3 + import NetworkingKit 4 + 5 + /// Endpoints for writing records to the user's PDS via standard AT Proto repo operations. 6 + enum RepoAPI: Sendable { 7 + case createRecord(body: Data) 8 + case deleteRecord(body: Data) 9 + case getRecord(repo: String, collection: String, rkey: String) 10 + case listRecords(repo: String, collection: String, limit: Int, cursor: String?) 11 + } 12 + 13 + extension RepoAPI: EndpointType { 14 + public var baseURL: URL? { 15 + get async { 16 + guard let host = await APEnvironment.current.host, 17 + let url = URL(string: host) else { 18 + return nil 19 + } 20 + return url 21 + } 22 + } 23 + 24 + var path: String { 25 + switch self { 26 + case .createRecord: "/xrpc/com.atproto.repo.createRecord" 27 + case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord" 28 + case .getRecord: "/xrpc/com.atproto.repo.getRecord" 29 + case .listRecords: "/xrpc/com.atproto.repo.listRecords" 30 + } 31 + } 32 + 33 + var httpMethod: HTTPMethod { 34 + switch self { 35 + case .createRecord, .deleteRecord: .post 36 + case .getRecord, .listRecords: .get 37 + } 38 + } 39 + 40 + var task: HTTPTask { 41 + switch self { 42 + case .createRecord(let body), .deleteRecord(let body): 43 + return .requestParameters(encoding: .jsonDataEncoding(data: body)) 44 + 45 + case .getRecord(let repo, let collection, let rkey): 46 + return .requestParameters(encoding: .urlEncoding(parameters: [ 47 + "repo": repo, 48 + "collection": collection, 49 + "rkey": rkey, 50 + ])) 51 + 52 + case .listRecords(let repo, let collection, let limit, let cursor): 53 + var params: Parameters = ["repo": repo, "collection": collection, "limit": limit] 54 + if let cursor { params["cursor"] = cursor } 55 + return .requestParameters(encoding: .urlEncoding(parameters: params)) 56 + } 57 + } 58 + 59 + var headers: HTTPHeaders? { nil } 60 + }
+219 -2
Tests/EffemKitTests/EffemKitTests.swift
··· 1 1 import Testing 2 + import Foundation 2 3 @testable import EffemKit 3 4 4 - @Test func example() async throws { 5 - // Write your test here and use APIs like `#expect(...)` to check expected conditions. 5 + @Suite("PodcastRef") 6 + struct PodcastRefTests { 7 + @Test func decodesFromJSON() throws { 8 + let json = """ 9 + {"feedId": 75075, "feedUrl": "https://example.com/feed.xml", "podcastGuid": "917393e3-1b1e-5cef-ace4-edaa54e1f810"} 10 + """.data(using: .utf8)! 11 + 12 + let ref = try JSONDecoder().decode(PodcastRef.self, from: json) 13 + #expect(ref.feedId == 75075) 14 + #expect(ref.feedUrl == "https://example.com/feed.xml") 15 + #expect(ref.podcastGuid == "917393e3-1b1e-5cef-ace4-edaa54e1f810") 16 + } 17 + 18 + @Test func decodesWithMinimalFields() throws { 19 + let json = """ 20 + {"feedId": 42} 21 + """.data(using: .utf8)! 22 + 23 + let ref = try JSONDecoder().decode(PodcastRef.self, from: json) 24 + #expect(ref.feedId == 42) 25 + #expect(ref.feedUrl == nil) 26 + #expect(ref.podcastGuid == nil) 27 + } 28 + 29 + @Test func roundTrips() throws { 30 + let ref = PodcastRef(feedId: 100, feedUrl: "https://example.com/rss", podcastGuid: "abc-123") 31 + let data = try JSONEncoder().encode(ref) 32 + let decoded = try JSONDecoder().decode(PodcastRef.self, from: data) 33 + #expect(ref == decoded) 34 + } 35 + 36 + @Test func toRecordIncludesOnlyNonNilFields() { 37 + let minimal = PodcastRef(feedId: 1) 38 + let record = minimal.toRecord() 39 + #expect(record["feedId"] as? Int == 1) 40 + #expect(record["feedUrl"] == nil) 41 + #expect(record["podcastGuid"] == nil) 42 + 43 + let full = PodcastRef(feedId: 2, feedUrl: "https://x.com/rss", podcastGuid: "guid") 44 + let fullRecord = full.toRecord() 45 + #expect(fullRecord["feedId"] as? Int == 2) 46 + #expect(fullRecord["feedUrl"] as? String == "https://x.com/rss") 47 + #expect(fullRecord["podcastGuid"] as? String == "guid") 48 + } 49 + } 50 + 51 + @Suite("EpisodeRef") 52 + struct EpisodeRefTests { 53 + @Test func decodesFromJSON() throws { 54 + let json = """ 55 + {"feedId": 75075, "episodeId": 1721351091, "episodeGuid": "ac34129c", "podcastGuid": "917393e3"} 56 + """.data(using: .utf8)! 57 + 58 + let ref = try JSONDecoder().decode(EpisodeRef.self, from: json) 59 + #expect(ref.feedId == 75075) 60 + #expect(ref.episodeId == 1721351091) 61 + #expect(ref.episodeGuid == "ac34129c") 62 + #expect(ref.podcastGuid == "917393e3") 63 + } 64 + 65 + @Test func decodesWithMinimalFields() throws { 66 + let json = """ 67 + {"feedId": 10, "episodeId": 20} 68 + """.data(using: .utf8)! 69 + 70 + let ref = try JSONDecoder().decode(EpisodeRef.self, from: json) 71 + #expect(ref.feedId == 10) 72 + #expect(ref.episodeId == 20) 73 + #expect(ref.episodeGuid == nil) 74 + } 75 + 76 + @Test func roundTrips() throws { 77 + let ref = EpisodeRef(feedId: 5, episodeId: 10, episodeGuid: "ep-guid") 78 + let data = try JSONEncoder().encode(ref) 79 + let decoded = try JSONDecoder().decode(EpisodeRef.self, from: data) 80 + #expect(ref == decoded) 81 + } 82 + 83 + @Test func toRecordIncludesOnlyNonNilFields() { 84 + let minimal = EpisodeRef(feedId: 1, episodeId: 2) 85 + let record = minimal.toRecord() 86 + #expect(record["feedId"] as? Int == 1) 87 + #expect(record["episodeId"] as? Int == 2) 88 + #expect(record["episodeGuid"] == nil) 89 + } 90 + } 91 + 92 + @Suite("Comment") 93 + struct CommentTests { 94 + @Test func decodesFromJSON() throws { 95 + let json = """ 96 + { 97 + "did": "did:plc:abc123", 98 + "rkey": "3jm2szx5c47mo", 99 + "episode": {"feedId": 75075, "episodeId": 100}, 100 + "text": "Great episode!", 101 + "timestamp": 120, 102 + "createdAt": "2026-01-15T10:30:00Z", 103 + "author": { 104 + "did": "did:plc:abc123", 105 + "handle": "alice.bsky.social", 106 + "displayName": "Alice" 107 + } 108 + } 109 + """.data(using: .utf8)! 110 + 111 + let comment = try JSONDecoder().decode(EffemKit.Comment.self, from: json) 112 + #expect(comment.did == "did:plc:abc123") 113 + #expect(comment.text == "Great episode!") 114 + #expect(comment.timestamp == 120) 115 + #expect(comment.episode.feedId == 75075) 116 + #expect(comment.author?.displayName == "Alice") 117 + #expect(comment.uri == "at://did:plc:abc123/xyz.effem.feed.comment/3jm2szx5c47mo") 118 + } 119 + } 120 + 121 + @Suite("Subscription") 122 + struct SubscriptionTests { 123 + @Test func uriIsCorrect() throws { 124 + let json = """ 125 + { 126 + "did": "did:plc:xyz", 127 + "rkey": "abc123", 128 + "podcast": {"feedId": 42}, 129 + "createdAt": "2026-01-01T00:00:00Z" 130 + } 131 + """.data(using: .utf8)! 132 + 133 + let sub = try JSONDecoder().decode(Subscription.self, from: json) 134 + #expect(sub.uri == "at://did:plc:xyz/xyz.effem.feed.subscription/abc123") 135 + #expect(sub.id == "did:plc:xyz/abc123") 136 + } 137 + } 138 + 139 + @Suite("SocialOverlay") 140 + struct SocialOverlayTests { 141 + @Test func decodesPartialFields() throws { 142 + let json = """ 143 + {"subscriberCount": 42, "commentCount": 10} 144 + """.data(using: .utf8)! 145 + 146 + let overlay = try JSONDecoder().decode(SocialOverlay.self, from: json) 147 + #expect(overlay.subscriberCount == 42) 148 + #expect(overlay.commentCount == 10) 149 + #expect(overlay.recommendationCount == nil) 150 + #expect(overlay.bookmarkCount == nil) 151 + #expect(overlay.subscribedByFollowing == nil) 152 + } 153 + 154 + @Test func decodesFullFields() throws { 155 + let json = """ 156 + { 157 + "subscriberCount": 100, 158 + "commentCount": 50, 159 + "recommendationCount": 25, 160 + "bookmarkCount": 10, 161 + "subscribedByFollowing": ["did:plc:a", "did:plc:b"] 162 + } 163 + """.data(using: .utf8)! 164 + 165 + let overlay = try JSONDecoder().decode(SocialOverlay.self, from: json) 166 + #expect(overlay.subscriberCount == 100) 167 + #expect(overlay.subscribedByFollowing?.count == 2) 168 + } 169 + } 170 + 171 + @Suite("Bookmark") 172 + struct BookmarkTests { 173 + @Test func decodesFromJSON() throws { 174 + let json = """ 175 + { 176 + "did": "did:plc:xyz", 177 + "rkey": "tid123", 178 + "episode": {"feedId": 1, "episodeId": 2}, 179 + "timestamp": 300, 180 + "createdAt": "2026-02-01T00:00:00Z" 181 + } 182 + """.data(using: .utf8)! 183 + 184 + let bookmark = try JSONDecoder().decode(Bookmark.self, from: json) 185 + #expect(bookmark.timestamp == 300) 186 + #expect(bookmark.uri == "at://did:plc:xyz/xyz.effem.feed.bookmark/tid123") 187 + } 188 + } 189 + 190 + @Suite("PodcastList") 191 + struct PodcastListTests { 192 + @Test func decodesFromJSON() throws { 193 + let json = """ 194 + { 195 + "did": "did:plc:xyz", 196 + "rkey": "list1", 197 + "name": "My Favorite Pods", 198 + "description": "The best ones", 199 + "podcasts": [{"feedId": 1}, {"feedId": 2, "feedUrl": "https://example.com/rss"}], 200 + "createdAt": "2026-01-01T00:00:00Z" 201 + } 202 + """.data(using: .utf8)! 203 + 204 + let list = try JSONDecoder().decode(PodcastList.self, from: json) 205 + #expect(list.name == "My Favorite Pods") 206 + #expect(list.podcasts.count == 2) 207 + #expect(list.podcasts[1].feedUrl == "https://example.com/rss") 208 + } 209 + } 210 + 211 + @Suite("CommentReplyRef") 212 + struct CommentReplyRefTests { 213 + @Test func initializesCorrectly() { 214 + let ref = CommentReplyRef( 215 + rootURI: "at://did:plc:a/xyz.effem.feed.comment/root", 216 + rootCID: "bafyreia", 217 + parentURI: "at://did:plc:b/xyz.effem.feed.comment/parent", 218 + parentCID: "bafyreib" 219 + ) 220 + #expect(ref.rootURI.contains("root")) 221 + #expect(ref.parentCID == "bafyreib") 222 + } 6 223 }