···1+# ``EffemKit``
2+3+Swift client for the Effem AT Protocol AppView — social podcasting on the AT Protocol.
4+5+## Overview
6+7+EffemKit provides an iOS client for interacting with the Effem AppView, a custom AT Protocol service that adds social features to podcast listening. The package handles two distinct communication paths:
8+9+- **Read path** — ``EffemService`` queries the Effem AppView for social data (comments, subscriptions, recommendations) and Podcast Index metadata enriched with social overlays.
10+- **Write path** — ``EffemRepoService`` writes records to the user's AT Protocol PDS (Personal Data Server), which the AppView then indexes asynchronously via the AT Protocol firehose.
11+12+All public API is isolated to the `@APActor` global actor from CoreATProtocol, providing thread-safe access without manual synchronization.
13+14+## Topics
15+16+### Essentials
17+18+- <doc:GettingStarted>
19+- ``setup(appViewHost:)``
20+- ``EffemEnvironment``
21+22+### Reading Data
23+24+- <doc:ReadingData>
25+- ``EffemService``
26+27+### Writing Data
28+29+- <doc:WritingData>
30+- ``EffemRepoService``
31+- ``CommentReplyRef``
32+33+### Models — AT Protocol Records
34+35+- ``Subscription``
36+- ``Comment``
37+- ``CommentAuthor``
38+- ``Recommendation``
39+- ``Bookmark``
40+- ``PodcastList``
41+- ``EffemProfile``
42+43+### Models — Podcast Index
44+45+- ``PodcastRef``
46+- ``EpisodeRef``
47+- ``PodcastResult``
48+- ``EpisodeResult``
49+- ``PodcastCategory``
50+- ``SocialOverlay``
51+- ``InboxItem``
52+53+### Models — Responses
54+55+- ``SubscriptionsResponse``
56+- ``SubscribersResponse``
57+- ``SubscriberInfo``
58+- ``CommentsResponse``
59+- ``CommentThreadResponse``
60+- ``CommentThreadNode``
61+- ``RecommendationsResponse``
62+- ``PopularResponse``
63+- ``PopularEpisode``
64+- ``ListsResponse``
65+- ``ListResponse``
66+- ``BookmarksResponse``
67+- ``ProfileResponse``
68+- ``PodcastSearchResponse``
69+- ``PodcastDetailResponse``
70+- ``EpisodeSearchResponse``
71+- ``EpisodesResponse``
72+- ``EpisodeDetailResponse``
73+- ``TrendingResponse``
74+- ``CategoriesResponse``
75+- ``InboxResponse``
76+- ``CreateRecordResponse``
77+78+### Errors
79+80+- ``EffemKitConfigurationError``
81+- ``EffemRepoError``
···1+# Getting Started with EffemKit
2+3+Configure EffemKit and make your first API calls.
4+5+## Overview
6+7+EffemKit requires two pieces of configuration before use:
8+9+1. **AppView host** — The URL of your Effem AppView server (for reading social and podcast data).
10+2. **PDS authentication** — An authenticated CoreATProtocol session (for writing records to the user's repository).
11+12+Read-only features (browsing podcasts, viewing comments) only need the AppView host. Write features (subscribing, commenting, recommending) additionally require PDS authentication.
13+14+## Configure the AppView
15+16+Call ``setup(appViewHost:)`` once at app launch, typically in your `App` initializer or an early scene phase handler:
17+18+```swift
19+import EffemKit
20+import CoreATProtocol
21+22+@main
23+struct EffemApp: App {
24+ init() {
25+ Task { @APActor in
26+ setup(appViewHost: "https://appview.effem.xyz")
27+ }
28+ }
29+30+ var body: some Scene {
31+ WindowGroup {
32+ ContentView()
33+ }
34+ }
35+}
36+```
37+38+> Important: All EffemKit API is isolated to `@APActor`. Call EffemKit methods from within an `@APActor`-isolated context or use `Task { @APActor in ... }`.
39+40+## Authenticate the User
41+42+EffemKit relies on CoreATProtocol for OAuth authentication. Once the user signs in through CoreATProtocol, the PDS host is automatically available and ``EffemRepoService`` can write records:
43+44+```swift
45+// After CoreATProtocol OAuth flow completes,
46+// APEnvironment.current.host is set to the user's PDS.
47+// No additional EffemKit configuration is needed for writes.
48+```
49+50+## Read Data with EffemService
51+52+``EffemService`` provides all read-only queries. Create an instance and call any method:
53+54+```swift
55+@APActor
56+func loadTrending() async throws -> [PodcastResult] {
57+ let service = EffemService()
58+ let response = try await service.getTrending(max: 20)
59+ return response.feeds
60+}
61+```
62+63+```swift
64+@APActor
65+func loadComments(feedId: Int, episodeId: Int) async throws -> [Comment] {
66+ let service = EffemService()
67+ let response = try await service.getComments(
68+ feedId: feedId,
69+ episodeId: episodeId
70+ )
71+ return response.comments
72+}
73+```
74+75+## Write Data with EffemRepoService
76+77+``EffemRepoService`` writes records to the user's PDS. The `repo` parameter is the user's DID:
78+79+```swift
80+@APActor
81+func subscribeToPodcast(feedId: Int, userDID: String) async throws {
82+ let repoService = EffemRepoService()
83+ let podcast = PodcastRef(feedId: feedId)
84+ let response = try await repoService.subscribe(to: podcast, repo: userDID)
85+ // response.uri contains the AT URI of the created record
86+ // response.cid contains the content hash
87+}
88+```
89+90+## Handle Pagination
91+92+Most list endpoints support cursor-based pagination:
93+94+```swift
95+@APActor
96+func loadAllSubscriptions(did: String) async throws -> [Subscription] {
97+ let service = EffemService()
98+ var allSubscriptions: [Subscription] = []
99+ var cursor: String?
100+101+ repeat {
102+ let response = try await service.getSubscriptions(
103+ did: did,
104+ cursor: cursor,
105+ limit: 50
106+ )
107+ allSubscriptions.append(contentsOf: response.subscriptions)
108+ cursor = response.cursor
109+ } while cursor != nil
110+111+ return allSubscriptions
112+}
113+```
114+115+## Error Handling
116+117+EffemKit throws ``EffemKitConfigurationError`` when the AppView or PDS isn't configured:
118+119+```swift
120+@APActor
121+func safeFetch() async {
122+ let service = EffemService()
123+ do {
124+ let trending = try await service.getTrending()
125+ // use trending.feeds
126+ } catch let error as EffemKitConfigurationError {
127+ // Handle missing configuration
128+ print(error.localizedDescription)
129+ } catch {
130+ // Handle network or decoding errors
131+ print(error.localizedDescription)
132+ }
133+}
134+```
···1+# Reading Data from the AppView
2+3+Query social data, podcast metadata, and search results through the Effem AppView.
4+5+## Overview
6+7+``EffemService`` is the single entry point for all read operations. Every method maps to an XRPC endpoint on the Effem AppView, returning strongly-typed response models.
8+9+The AppView serves two categories of data:
10+11+- **Social data** — Records written by users (subscriptions, comments, recommendations, bookmarks, lists) that the AppView indexed from the AT Protocol firehose.
12+- **Podcast metadata** — Podcast Index data proxied and cached by the AppView, enriched with ``SocialOverlay`` counts.
13+14+## Discover Podcasts
15+16+Search for podcasts and episodes, browse trending content, or look up specific metadata:
17+18+```swift
19+@APActor
20+func discoverPodcasts() async throws {
21+ let service = EffemService()
22+23+ // Search
24+ let searchResults = try await service.searchPodcasts(query: "swift")
25+ for podcast in searchResults.feeds {
26+ print("\(podcast.title ?? "Untitled") — \(podcast.social?.subscriberCount ?? 0) subscribers")
27+ }
28+29+ // Trending
30+ let trending = try await service.getTrending(max: 10, lang: "en")
31+32+ // By category
33+ let categories = try await service.getCategories()
34+35+ // Podcast detail with social overlay
36+ let detail = try await service.getPodcast(feedId: 75075)
37+ print("Subscribers: \(detail.feed.social?.subscriberCount ?? 0)")
38+}
39+```
40+41+## Browse Episodes
42+43+```swift
44+@APActor
45+func browseEpisodes(feedId: Int) async throws {
46+ let service = EffemService()
47+48+ // List episodes for a podcast
49+ let episodes = try await service.getEpisodes(feedId: feedId, max: 20)
50+51+ // Single episode detail
52+ if let first = episodes.items.first {
53+ let detail = try await service.getEpisode(episodeId: first.episodeId)
54+ }
55+56+ // Search episodes
57+ let results = try await service.searchEpisodes(query: "WWDC")
58+}
59+```
60+61+## Social Features
62+63+### Subscriptions
64+65+```swift
66+@APActor
67+func viewSubscriptions(userDID: String) async throws {
68+ let service = EffemService()
69+70+ // A user's subscriptions
71+ let subs = try await service.getSubscriptions(did: userDID)
72+ for sub in subs.subscriptions {
73+ print("Subscribed to feed \(sub.podcast.feedId) on \(sub.createdAt)")
74+ }
75+76+ // Who subscribes to a specific podcast
77+ let subscribers = try await service.getSubscribers(feedId: 75075)
78+ for subscriber in subscribers.subscribers {
79+ print("\(subscriber.displayName ?? subscriber.did)")
80+ }
81+}
82+```
83+84+### Comments
85+86+```swift
87+@APActor
88+func viewComments(feedId: Int, episodeId: Int) async throws {
89+ let service = EffemService()
90+91+ // Flat list of comments for an episode
92+ let comments = try await service.getComments(
93+ feedId: feedId,
94+ episodeId: episodeId
95+ )
96+97+ // Threaded view of a specific comment
98+ if let first = comments.comments.first {
99+ let thread = try await service.getCommentThread(uri: first.uri)
100+ // thread.thread.comment is the root
101+ // thread.thread.replies contains nested replies
102+ }
103+}
104+```
105+106+### Recommendations and Popular Episodes
107+108+```swift
109+@APActor
110+func viewRecommendations(feedId: Int, episodeId: Int) async throws {
111+ let service = EffemService()
112+113+ // Recommendations for a specific episode
114+ let recs = try await service.getRecommendations(
115+ feedId: feedId,
116+ episodeId: episodeId
117+ )
118+119+ // Most recommended episodes this week
120+ let popular = try await service.getPopular(period: "week", limit: 10)
121+ for ep in popular.episodes {
122+ print("\(ep.title ?? "Untitled") — \(ep.recommendationCount) recs")
123+ }
124+}
125+```
126+127+### Bookmarks, Lists, and Inbox
128+129+```swift
130+@APActor
131+func viewUserContent(userDID: String) async throws {
132+ let service = EffemService()
133+134+ // Bookmarks (optionally filtered by podcast or episode)
135+ let bookmarks = try await service.getBookmarks(did: userDID)
136+137+ // Curated podcast lists
138+ let lists = try await service.getLists(did: userDID)
139+ if let first = lists.lists.first {
140+ let detail = try await service.getList(uri: first.uri)
141+ }
142+143+ // New episodes from subscribed podcasts
144+ let inbox = try await service.getInbox(did: userDID)
145+ for item in inbox.items {
146+ print("New: \(item.episode.title ?? "Untitled") from \(item.podcastTitle ?? "Unknown")")
147+ }
148+}
149+```
150+151+### Profiles
152+153+```swift
154+@APActor
155+func viewProfile(userDID: String) async throws {
156+ let service = EffemService()
157+ let profile = try await service.getProfile(did: userDID)
158+ print("""
159+ \(profile.profile.displayName ?? profile.profile.did)
160+ Subscriptions: \(profile.profile.subscriptionCount ?? 0)
161+ Comments: \(profile.profile.commentCount ?? 0)
162+ Genres: \(profile.profile.favoriteGenres?.joined(separator: ", ") ?? "none")
163+ """)
164+}
165+```
166+167+## Social Overlay
168+169+Many podcast and episode responses include a ``SocialOverlay`` with engagement counts. This data is computed by the AppView from indexed AT Protocol records:
170+171+```swift
172+if let social = podcastResult.social {
173+ // How many Effem users subscribe to this podcast
174+ let subscribers = social.subscriberCount ?? 0
175+176+ // How many comments exist across all episodes
177+ let comments = social.commentCount ?? 0
178+179+ // Which of the current user's follows also subscribe
180+ let followingWhoSubscribe = social.subscribedByFollowing ?? []
181+}
182+```
···1+# Writing Data to the AT Protocol
2+3+Create and delete records in the user's AT Protocol repository.
4+5+## Overview
6+7+``EffemRepoService`` writes Effem records to the authenticated user's PDS (Personal Data Server) using standard AT Protocol repository operations (`com.atproto.repo.createRecord` and `com.atproto.repo.deleteRecord`).
8+9+Records are written to the user's PDS, not the AppView. The AppView discovers new records asynchronously by subscribing to the AT Protocol firehose. There may be a brief delay (typically under a second) between writing a record and it appearing in AppView query results.
10+11+> Important: The user must be authenticated via CoreATProtocol before calling any ``EffemRepoService`` method. If the PDS host isn't configured, methods throw ``EffemKitConfigurationError/pdsHostNotConfigured``.
12+13+## Subscribe to a Podcast
14+15+```swift
16+@APActor
17+func subscribe(feedId: Int, feedUrl: String?, userDID: String) async throws {
18+ let repoService = EffemRepoService()
19+ let podcast = PodcastRef(feedId: feedId, feedUrl: feedUrl)
20+21+ let response = try await repoService.subscribe(to: podcast, repo: userDID)
22+ // response.uri = "at://did:plc:xxx/xyz.effem.feed.subscription/3jm2..."
23+ // response.cid = content hash of the record
24+25+ // Extract the rkey from the URI for later deletion
26+ let rkey = response.uri.split(separator: "/").last.map(String.init) ?? ""
27+}
28+```
29+30+To unsubscribe, pass the record key from the original subscription:
31+32+```swift
33+@APActor
34+func unsubscribe(rkey: String, userDID: String) async throws {
35+ let repoService = EffemRepoService()
36+ try await repoService.unsubscribe(rkey: rkey, repo: userDID)
37+}
38+```
39+40+## Post a Comment
41+42+Comments are attached to a specific episode. You can optionally include a playback timestamp and a reply reference for threading:
43+44+```swift
45+@APActor
46+func postComment(
47+ feedId: Int,
48+ episodeId: Int,
49+ text: String,
50+ timestamp: Int?,
51+ userDID: String
52+) async throws {
53+ let repoService = EffemRepoService()
54+ let episode = EpisodeRef(feedId: feedId, episodeId: episodeId)
55+56+ let response = try await repoService.postComment(
57+ episode: episode,
58+ text: text,
59+ timestamp: timestamp,
60+ repo: userDID
61+ )
62+}
63+```
64+65+### Reply to a Comment
66+67+To reply to an existing comment, construct a ``CommentReplyRef`` with the root and parent comment URIs and CIDs:
68+69+```swift
70+@APActor
71+func replyToComment(
72+ episode: EpisodeRef,
73+ text: String,
74+ parentComment: Comment,
75+ rootComment: Comment,
76+ rootCID: String,
77+ parentCID: String,
78+ userDID: String
79+) async throws {
80+ let repoService = EffemRepoService()
81+82+ let reply = CommentReplyRef(
83+ rootURI: rootComment.uri,
84+ rootCID: rootCID,
85+ parentURI: parentComment.uri,
86+ parentCID: parentCID
87+ )
88+89+ let response = try await repoService.postComment(
90+ episode: episode,
91+ text: text,
92+ reply: reply,
93+ repo: userDID
94+ )
95+}
96+```
97+98+## Recommend an Episode
99+100+```swift
101+@APActor
102+func recommend(feedId: Int, episodeId: Int, text: String?, userDID: String) async throws {
103+ let repoService = EffemRepoService()
104+ let episode = EpisodeRef(feedId: feedId, episodeId: episodeId)
105+106+ let response = try await repoService.recommend(
107+ episode: episode,
108+ text: text,
109+ repo: userDID
110+ )
111+}
112+```
113+114+## Bookmark an Episode
115+116+Bookmarks can include a playback timestamp so the user can return to a specific moment:
117+118+```swift
119+@APActor
120+func bookmarkCurrentPosition(
121+ feedId: Int,
122+ episodeId: Int,
123+ playbackSeconds: Int,
124+ userDID: String
125+) async throws {
126+ let repoService = EffemRepoService()
127+ let episode = EpisodeRef(feedId: feedId, episodeId: episodeId)
128+129+ let response = try await repoService.bookmark(
130+ episode: episode,
131+ timestamp: playbackSeconds,
132+ repo: userDID
133+ )
134+}
135+```
136+137+## Create a Podcast List
138+139+Curated lists group multiple podcasts under a name and optional description:
140+141+```swift
142+@APActor
143+func createList(userDID: String) async throws {
144+ let repoService = EffemRepoService()
145+146+ let podcasts = [
147+ PodcastRef(feedId: 75075, feedUrl: "https://example.com/feed.xml"),
148+ PodcastRef(feedId: 920666),
149+ ]
150+151+ let response = try await repoService.createList(
152+ name: "My Favorite Tech Pods",
153+ description: "The best podcasts about Swift and iOS development",
154+ podcasts: podcasts,
155+ repo: userDID
156+ )
157+}
158+```
159+160+## Update User Profile
161+162+The Effem profile is stored as a single record with the fixed key `"self"`. Calling ``EffemRepoService/updateProfile(displayName:description:favoriteGenres:repo:)`` creates or overwrites it:
163+164+```swift
165+@APActor
166+func updateProfile(userDID: String) async throws {
167+ let repoService = EffemRepoService()
168+169+ let response = try await repoService.updateProfile(
170+ displayName: "Alice",
171+ description: "Podcast enthusiast and Swift developer",
172+ favoriteGenres: ["Technology", "Science", "Comedy"],
173+ repo: userDID
174+ )
175+}
176+```
177+178+## Delete Any Record
179+180+Every write method that creates a record has a corresponding delete method. All delete methods take an `rkey` (record key) and the user's DID:
181+182+```swift
183+@APActor
184+func deleteOperations(userDID: String) async throws {
185+ let repoService = EffemRepoService()
186+187+ try await repoService.unsubscribe(rkey: "3jm2abc", repo: userDID)
188+ try await repoService.deleteComment(rkey: "3jm2def", repo: userDID)
189+ try await repoService.unrecommend(rkey: "3jm2ghi", repo: userDID)
190+ try await repoService.removeBookmark(rkey: "3jm2jkl", repo: userDID)
191+ try await repoService.deleteList(rkey: "3jm2mno", repo: userDID)
192+}
193+```
194+195+## Understanding Record Keys
196+197+When you create a record, the PDS assigns a TID (timestamp-based identifier) as the record key. The ``CreateRecordResponse`` contains the full AT URI:
198+199+```
200+at://did:plc:abc123/xyz.effem.feed.subscription/3jm2szx5c47mo
201+│ │ │ │
202+│ │ │ └─ rkey (record key)
203+│ │ └─ collection (lexicon ID)
204+│ └─ repo (user's DID)
205+└─ AT URI scheme
206+```
207+208+Extract the `rkey` from the URI's last path component to use with delete methods. Store record keys locally (e.g., in SwiftData) so you can delete records later without querying the AppView.