diff --git a/Package.resolved b/Package.resolved index 1e4dd48..2731bb1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "29d06dd4d3fcf924c37d6591ec44fb6bc18d5097d9235a86a34df3d343359b60", + "originHash" : "c9e9e2667901cc2af4981bac3286deec3f228fc94d6e54128de7bb4da4d8d9af", "pins" : [ + { + "identity" : "coreatprotocol", + "kind" : "remoteSourceControl", + "location" : "https://tangled.org/@sparrowtek.com/CoreATProtocol", + "state" : { + "branch" : "main", + "revision" : "d50e4c6a1c92a5e777fe5c642173be03b14116e5" + } + }, { "identity" : "jwt-kit", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 91c1b14..2646787 100644 --- a/Package.swift +++ b/Package.swift @@ -18,8 +18,8 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../CoreATProtocol"), -// .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), +// .package(path: "../../CoreATProtocol"), + .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), ], targets: [ .target( diff --git a/README.md b/README.md new file mode 100644 index 0000000..0819473 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# EffemKit + +Swift client for the Effem AT Protocol AppView. Provides social podcasting features — subscriptions, comments, recommendations, bookmarks, curated lists, and profiles — built on the AT Protocol. + +## Requirements + +- Swift 6.2+ +- iOS 26+ / macOS 26+ / watchOS 26+ / tvOS 26+ + +## Dependencies + +- [CoreATProtocol](https://tangled.org/@sparrowtek.com/CoreATProtocol) — AT Protocol primitives, OAuth, networking + +## Installation + +Add EffemKit as a dependency in your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://tangled.org/@sparrowtek.com/EffemKit", branch: "main"), +] +``` + +Or add it as a local package in Xcode. + +## Quick Start + +### 1. Configure the AppView + +```swift +import EffemKit +import CoreATProtocol + +// At app launch +Task { @APActor in + setup(appViewHost: "https://appview.effem.xyz") +} +``` + +### 2. Read Data + +```swift +@APActor +func loadTrending() async throws -> [PodcastResult] { + let service = EffemService() + let response = try await service.getTrending(max: 20) + return response.feeds +} +``` + +### 3. Write Data (requires authentication) + +```swift +@APActor +func subscribe(feedId: Int, userDID: String) async throws { + let repoService = EffemRepoService() + let podcast = PodcastRef(feedId: feedId) + _ = try await repoService.subscribe(to: podcast, repo: userDID) +} +``` + +## Architecture + +EffemKit uses a two-router architecture: + +| Path | Service | Target | Auth Required | +|------|---------|--------|--------------| +| Read | `EffemService` | Effem AppView | No | +| Write | `EffemRepoService` | User's PDS | Yes | + +**Reads** go to the Effem AppView, which indexes social records from the AT Protocol firehose and proxies Podcast Index metadata enriched with social overlay data. + +**Writes** go to the authenticated user's PDS using standard `com.atproto.repo.createRecord` / `deleteRecord` calls. The AppView picks up new records asynchronously via the firehose. + +All public API is isolated to `@APActor` for thread safety. + +## API Overview + +### EffemService (Read) + +| Category | Methods | +|----------|---------| +| Subscriptions | `getSubscriptions`, `getSubscribers` | +| Comments | `getComments`, `getCommentThread` | +| Recommendations | `getRecommendations`, `getPopular` | +| Lists | `getList`, `getLists` | +| Bookmarks | `getBookmarks` | +| Inbox | `getInbox` | +| Profiles | `getProfile` | +| Podcast Search | `searchPodcasts`, `searchEpisodes` | +| Podcast Metadata | `getPodcast`, `getEpisodes`, `getEpisode`, `getTrending`, `getCategories` | + +### EffemRepoService (Write) + +| Category | Methods | +|----------|---------| +| Subscriptions | `subscribe`, `unsubscribe` | +| Comments | `postComment`, `deleteComment` | +| Recommendations | `recommend`, `unrecommend` | +| Bookmarks | `bookmark`, `removeBookmark` | +| Lists | `createList`, `deleteList` | +| Profile | `updateProfile` | + +## Lexicon Namespace + +All Effem records use the `xyz.effem.*` namespace: + +- `xyz.effem.feed.subscription` +- `xyz.effem.feed.comment` +- `xyz.effem.feed.recommendation` +- `xyz.effem.feed.bookmark` +- `xyz.effem.feed.list` +- `xyz.effem.actor.profile` + +## Documentation + +Build the DocC documentation: + +```bash +swift package generate-documentation +``` + +Or in Xcode: Product > Build Documentation. + +## Testing + +```bash +swift test +``` + +15 tests across 8 suites covering model decoding, round-tripping, serialization, and AT URI generation. diff --git a/Sources/EffemKit/EffemKit.docc/EffemKit.md b/Sources/EffemKit/EffemKit.docc/EffemKit.md new file mode 100644 index 0000000..d110848 --- /dev/null +++ b/Sources/EffemKit/EffemKit.docc/EffemKit.md @@ -0,0 +1,81 @@ +# ``EffemKit`` + +Swift client for the Effem AT Protocol AppView — social podcasting on the AT Protocol. + +## Overview + +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: + +- **Read path** — ``EffemService`` queries the Effem AppView for social data (comments, subscriptions, recommendations) and Podcast Index metadata enriched with social overlays. +- **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. + +All public API is isolated to the `@APActor` global actor from CoreATProtocol, providing thread-safe access without manual synchronization. + +## Topics + +### Essentials + +- +- ``setup(appViewHost:)`` +- ``EffemEnvironment`` + +### Reading Data + +- +- ``EffemService`` + +### Writing Data + +- +- ``EffemRepoService`` +- ``CommentReplyRef`` + +### Models — AT Protocol Records + +- ``Subscription`` +- ``Comment`` +- ``CommentAuthor`` +- ``Recommendation`` +- ``Bookmark`` +- ``PodcastList`` +- ``EffemProfile`` + +### Models — Podcast Index + +- ``PodcastRef`` +- ``EpisodeRef`` +- ``PodcastResult`` +- ``EpisodeResult`` +- ``PodcastCategory`` +- ``SocialOverlay`` +- ``InboxItem`` + +### Models — Responses + +- ``SubscriptionsResponse`` +- ``SubscribersResponse`` +- ``SubscriberInfo`` +- ``CommentsResponse`` +- ``CommentThreadResponse`` +- ``CommentThreadNode`` +- ``RecommendationsResponse`` +- ``PopularResponse`` +- ``PopularEpisode`` +- ``ListsResponse`` +- ``ListResponse`` +- ``BookmarksResponse`` +- ``ProfileResponse`` +- ``PodcastSearchResponse`` +- ``PodcastDetailResponse`` +- ``EpisodeSearchResponse`` +- ``EpisodesResponse`` +- ``EpisodeDetailResponse`` +- ``TrendingResponse`` +- ``CategoriesResponse`` +- ``InboxResponse`` +- ``CreateRecordResponse`` + +### Errors + +- ``EffemKitConfigurationError`` +- ``EffemRepoError`` diff --git a/Sources/EffemKit/EffemKit.docc/GettingStarted.md b/Sources/EffemKit/EffemKit.docc/GettingStarted.md new file mode 100644 index 0000000..a28166a --- /dev/null +++ b/Sources/EffemKit/EffemKit.docc/GettingStarted.md @@ -0,0 +1,134 @@ +# Getting Started with EffemKit + +Configure EffemKit and make your first API calls. + +## Overview + +EffemKit requires two pieces of configuration before use: + +1. **AppView host** — The URL of your Effem AppView server (for reading social and podcast data). +2. **PDS authentication** — An authenticated CoreATProtocol session (for writing records to the user's repository). + +Read-only features (browsing podcasts, viewing comments) only need the AppView host. Write features (subscribing, commenting, recommending) additionally require PDS authentication. + +## Configure the AppView + +Call ``setup(appViewHost:)`` once at app launch, typically in your `App` initializer or an early scene phase handler: + +```swift +import EffemKit +import CoreATProtocol + +@main +struct EffemApp: App { + init() { + Task { @APActor in + setup(appViewHost: "https://appview.effem.xyz") + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +> Important: All EffemKit API is isolated to `@APActor`. Call EffemKit methods from within an `@APActor`-isolated context or use `Task { @APActor in ... }`. + +## Authenticate the User + +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: + +```swift +// After CoreATProtocol OAuth flow completes, +// APEnvironment.current.host is set to the user's PDS. +// No additional EffemKit configuration is needed for writes. +``` + +## Read Data with EffemService + +``EffemService`` provides all read-only queries. Create an instance and call any method: + +```swift +@APActor +func loadTrending() async throws -> [PodcastResult] { + let service = EffemService() + let response = try await service.getTrending(max: 20) + return response.feeds +} +``` + +```swift +@APActor +func loadComments(feedId: Int, episodeId: Int) async throws -> [Comment] { + let service = EffemService() + let response = try await service.getComments( + feedId: feedId, + episodeId: episodeId + ) + return response.comments +} +``` + +## Write Data with EffemRepoService + +``EffemRepoService`` writes records to the user's PDS. The `repo` parameter is the user's DID: + +```swift +@APActor +func subscribeToPodcast(feedId: Int, userDID: String) async throws { + let repoService = EffemRepoService() + let podcast = PodcastRef(feedId: feedId) + let response = try await repoService.subscribe(to: podcast, repo: userDID) + // response.uri contains the AT URI of the created record + // response.cid contains the content hash +} +``` + +## Handle Pagination + +Most list endpoints support cursor-based pagination: + +```swift +@APActor +func loadAllSubscriptions(did: String) async throws -> [Subscription] { + let service = EffemService() + var allSubscriptions: [Subscription] = [] + var cursor: String? + + repeat { + let response = try await service.getSubscriptions( + did: did, + cursor: cursor, + limit: 50 + ) + allSubscriptions.append(contentsOf: response.subscriptions) + cursor = response.cursor + } while cursor != nil + + return allSubscriptions +} +``` + +## Error Handling + +EffemKit throws ``EffemKitConfigurationError`` when the AppView or PDS isn't configured: + +```swift +@APActor +func safeFetch() async { + let service = EffemService() + do { + let trending = try await service.getTrending() + // use trending.feeds + } catch let error as EffemKitConfigurationError { + // Handle missing configuration + print(error.localizedDescription) + } catch { + // Handle network or decoding errors + print(error.localizedDescription) + } +} +``` diff --git a/Sources/EffemKit/EffemKit.docc/ReadingData.md b/Sources/EffemKit/EffemKit.docc/ReadingData.md new file mode 100644 index 0000000..cbcddc2 --- /dev/null +++ b/Sources/EffemKit/EffemKit.docc/ReadingData.md @@ -0,0 +1,182 @@ +# Reading Data from the AppView + +Query social data, podcast metadata, and search results through the Effem AppView. + +## Overview + +``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. + +The AppView serves two categories of data: + +- **Social data** — Records written by users (subscriptions, comments, recommendations, bookmarks, lists) that the AppView indexed from the AT Protocol firehose. +- **Podcast metadata** — Podcast Index data proxied and cached by the AppView, enriched with ``SocialOverlay`` counts. + +## Discover Podcasts + +Search for podcasts and episodes, browse trending content, or look up specific metadata: + +```swift +@APActor +func discoverPodcasts() async throws { + let service = EffemService() + + // Search + let searchResults = try await service.searchPodcasts(query: "swift") + for podcast in searchResults.feeds { + print("\(podcast.title ?? "Untitled") — \(podcast.social?.subscriberCount ?? 0) subscribers") + } + + // Trending + let trending = try await service.getTrending(max: 10, lang: "en") + + // By category + let categories = try await service.getCategories() + + // Podcast detail with social overlay + let detail = try await service.getPodcast(feedId: 75075) + print("Subscribers: \(detail.feed.social?.subscriberCount ?? 0)") +} +``` + +## Browse Episodes + +```swift +@APActor +func browseEpisodes(feedId: Int) async throws { + let service = EffemService() + + // List episodes for a podcast + let episodes = try await service.getEpisodes(feedId: feedId, max: 20) + + // Single episode detail + if let first = episodes.items.first { + let detail = try await service.getEpisode(episodeId: first.episodeId) + } + + // Search episodes + let results = try await service.searchEpisodes(query: "WWDC") +} +``` + +## Social Features + +### Subscriptions + +```swift +@APActor +func viewSubscriptions(userDID: String) async throws { + let service = EffemService() + + // A user's subscriptions + let subs = try await service.getSubscriptions(did: userDID) + for sub in subs.subscriptions { + print("Subscribed to feed \(sub.podcast.feedId) on \(sub.createdAt)") + } + + // Who subscribes to a specific podcast + let subscribers = try await service.getSubscribers(feedId: 75075) + for subscriber in subscribers.subscribers { + print("\(subscriber.displayName ?? subscriber.did)") + } +} +``` + +### Comments + +```swift +@APActor +func viewComments(feedId: Int, episodeId: Int) async throws { + let service = EffemService() + + // Flat list of comments for an episode + let comments = try await service.getComments( + feedId: feedId, + episodeId: episodeId + ) + + // Threaded view of a specific comment + if let first = comments.comments.first { + let thread = try await service.getCommentThread(uri: first.uri) + // thread.thread.comment is the root + // thread.thread.replies contains nested replies + } +} +``` + +### Recommendations and Popular Episodes + +```swift +@APActor +func viewRecommendations(feedId: Int, episodeId: Int) async throws { + let service = EffemService() + + // Recommendations for a specific episode + let recs = try await service.getRecommendations( + feedId: feedId, + episodeId: episodeId + ) + + // Most recommended episodes this week + let popular = try await service.getPopular(period: "week", limit: 10) + for ep in popular.episodes { + print("\(ep.title ?? "Untitled") — \(ep.recommendationCount) recs") + } +} +``` + +### Bookmarks, Lists, and Inbox + +```swift +@APActor +func viewUserContent(userDID: String) async throws { + let service = EffemService() + + // Bookmarks (optionally filtered by podcast or episode) + let bookmarks = try await service.getBookmarks(did: userDID) + + // Curated podcast lists + let lists = try await service.getLists(did: userDID) + if let first = lists.lists.first { + let detail = try await service.getList(uri: first.uri) + } + + // New episodes from subscribed podcasts + let inbox = try await service.getInbox(did: userDID) + for item in inbox.items { + print("New: \(item.episode.title ?? "Untitled") from \(item.podcastTitle ?? "Unknown")") + } +} +``` + +### Profiles + +```swift +@APActor +func viewProfile(userDID: String) async throws { + let service = EffemService() + let profile = try await service.getProfile(did: userDID) + print(""" + \(profile.profile.displayName ?? profile.profile.did) + Subscriptions: \(profile.profile.subscriptionCount ?? 0) + Comments: \(profile.profile.commentCount ?? 0) + Genres: \(profile.profile.favoriteGenres?.joined(separator: ", ") ?? "none") + """) +} +``` + +## Social Overlay + +Many podcast and episode responses include a ``SocialOverlay`` with engagement counts. This data is computed by the AppView from indexed AT Protocol records: + +```swift +if let social = podcastResult.social { + // How many Effem users subscribe to this podcast + let subscribers = social.subscriberCount ?? 0 + + // How many comments exist across all episodes + let comments = social.commentCount ?? 0 + + // Which of the current user's follows also subscribe + let followingWhoSubscribe = social.subscribedByFollowing ?? [] +} +``` diff --git a/Sources/EffemKit/EffemKit.docc/WritingData.md b/Sources/EffemKit/EffemKit.docc/WritingData.md new file mode 100644 index 0000000..61768f7 --- /dev/null +++ b/Sources/EffemKit/EffemKit.docc/WritingData.md @@ -0,0 +1,208 @@ +# Writing Data to the AT Protocol + +Create and delete records in the user's AT Protocol repository. + +## Overview + +``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`). + +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. + +> Important: The user must be authenticated via CoreATProtocol before calling any ``EffemRepoService`` method. If the PDS host isn't configured, methods throw ``EffemKitConfigurationError/pdsHostNotConfigured``. + +## Subscribe to a Podcast + +```swift +@APActor +func subscribe(feedId: Int, feedUrl: String?, userDID: String) async throws { + let repoService = EffemRepoService() + let podcast = PodcastRef(feedId: feedId, feedUrl: feedUrl) + + let response = try await repoService.subscribe(to: podcast, repo: userDID) + // response.uri = "at://did:plc:xxx/xyz.effem.feed.subscription/3jm2..." + // response.cid = content hash of the record + + // Extract the rkey from the URI for later deletion + let rkey = response.uri.split(separator: "/").last.map(String.init) ?? "" +} +``` + +To unsubscribe, pass the record key from the original subscription: + +```swift +@APActor +func unsubscribe(rkey: String, userDID: String) async throws { + let repoService = EffemRepoService() + try await repoService.unsubscribe(rkey: rkey, repo: userDID) +} +``` + +## Post a Comment + +Comments are attached to a specific episode. You can optionally include a playback timestamp and a reply reference for threading: + +```swift +@APActor +func postComment( + feedId: Int, + episodeId: Int, + text: String, + timestamp: Int?, + userDID: String +) async throws { + let repoService = EffemRepoService() + let episode = EpisodeRef(feedId: feedId, episodeId: episodeId) + + let response = try await repoService.postComment( + episode: episode, + text: text, + timestamp: timestamp, + repo: userDID + ) +} +``` + +### Reply to a Comment + +To reply to an existing comment, construct a ``CommentReplyRef`` with the root and parent comment URIs and CIDs: + +```swift +@APActor +func replyToComment( + episode: EpisodeRef, + text: String, + parentComment: Comment, + rootComment: Comment, + rootCID: String, + parentCID: String, + userDID: String +) async throws { + let repoService = EffemRepoService() + + let reply = CommentReplyRef( + rootURI: rootComment.uri, + rootCID: rootCID, + parentURI: parentComment.uri, + parentCID: parentCID + ) + + let response = try await repoService.postComment( + episode: episode, + text: text, + reply: reply, + repo: userDID + ) +} +``` + +## Recommend an Episode + +```swift +@APActor +func recommend(feedId: Int, episodeId: Int, text: String?, userDID: String) async throws { + let repoService = EffemRepoService() + let episode = EpisodeRef(feedId: feedId, episodeId: episodeId) + + let response = try await repoService.recommend( + episode: episode, + text: text, + repo: userDID + ) +} +``` + +## Bookmark an Episode + +Bookmarks can include a playback timestamp so the user can return to a specific moment: + +```swift +@APActor +func bookmarkCurrentPosition( + feedId: Int, + episodeId: Int, + playbackSeconds: Int, + userDID: String +) async throws { + let repoService = EffemRepoService() + let episode = EpisodeRef(feedId: feedId, episodeId: episodeId) + + let response = try await repoService.bookmark( + episode: episode, + timestamp: playbackSeconds, + repo: userDID + ) +} +``` + +## Create a Podcast List + +Curated lists group multiple podcasts under a name and optional description: + +```swift +@APActor +func createList(userDID: String) async throws { + let repoService = EffemRepoService() + + let podcasts = [ + PodcastRef(feedId: 75075, feedUrl: "https://example.com/feed.xml"), + PodcastRef(feedId: 920666), + ] + + let response = try await repoService.createList( + name: "My Favorite Tech Pods", + description: "The best podcasts about Swift and iOS development", + podcasts: podcasts, + repo: userDID + ) +} +``` + +## Update User Profile + +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: + +```swift +@APActor +func updateProfile(userDID: String) async throws { + let repoService = EffemRepoService() + + let response = try await repoService.updateProfile( + displayName: "Alice", + description: "Podcast enthusiast and Swift developer", + favoriteGenres: ["Technology", "Science", "Comedy"], + repo: userDID + ) +} +``` + +## Delete Any Record + +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: + +```swift +@APActor +func deleteOperations(userDID: String) async throws { + let repoService = EffemRepoService() + + try await repoService.unsubscribe(rkey: "3jm2abc", repo: userDID) + try await repoService.deleteComment(rkey: "3jm2def", repo: userDID) + try await repoService.unrecommend(rkey: "3jm2ghi", repo: userDID) + try await repoService.removeBookmark(rkey: "3jm2jkl", repo: userDID) + try await repoService.deleteList(rkey: "3jm2mno", repo: userDID) +} +``` + +## Understanding Record Keys + +When you create a record, the PDS assigns a TID (timestamp-based identifier) as the record key. The ``CreateRecordResponse`` contains the full AT URI: + +``` +at://did:plc:abc123/xyz.effem.feed.subscription/3jm2szx5c47mo +│ │ │ │ +│ │ │ └─ rkey (record key) +│ │ └─ collection (lexicon ID) +│ └─ repo (user's DID) +└─ AT URI scheme +``` + +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.