diff --git a/Sources/EffemKit/ATDate.swift b/Sources/EffemKit/ATDate.swift new file mode 100644 index 0000000..06b385a --- /dev/null +++ b/Sources/EffemKit/ATDate.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Shared helpers for AT Protocol datetime strings. +/// +/// Writes always include fractional seconds so records pass strict lexicon +/// validation. Reads accept either form, since older records and other clients +/// may emit timestamps without subseconds. +/// +/// Formatters are constructed per-call rather than cached statically because +/// `ISO8601DateFormatter` is not `Sendable` under Swift 6 strict concurrency. +/// The allocation cost is negligible — these calls happen at human cadence. +public enum ATDate { + + /// Returns the current instant formatted as an AT Protocol datetime string + /// with millisecond precision and a `Z` timezone designator. + public static func now() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + } + + /// Parses an AT Protocol datetime string, accepting either fractional-second + /// or whole-second precision. Returns `nil` if neither form succeeds. + public static func parse(_ string: String) -> Date? { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: string) { + return date + } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: string) + } +} diff --git a/Sources/EffemKit/CommentSubscription.swift b/Sources/EffemKit/CommentSubscription.swift new file mode 100644 index 0000000..47a2e1d --- /dev/null +++ b/Sources/EffemKit/CommentSubscription.swift @@ -0,0 +1,76 @@ +import Foundation +import CoreATProtocol + +/// Streaming consumer for the `xyz.effem.feed.subscribeComments` websocket. +/// Each yielded `CommentEvent` is one indexed change to the subject's +/// comment graph (create / delete / like / unlike). +/// +/// Implemented as an `AsyncThrowingStream` so call sites can `for-await` +/// inside an SwiftUI `.task` block and cancellation drops the underlying +/// `URLSessionWebSocketTask` automatically. +enum CommentSubscription { + /// Builds the WS URL by swapping the AppView host's scheme to ws/wss + /// and appending the subscription path + query string. + static func makeURL(subject: String) async -> URL? { + guard let host = await EffemEnvironment.current.appViewHost, + var components = URLComponents(string: host) else { + return nil + } + switch components.scheme { + case "http": components.scheme = "ws" + case "https": components.scheme = "wss" + default: break + } + components.path = "/xrpc/xyz.effem.feed.subscribeComments" + components.queryItems = [URLQueryItem(name: "subject", value: subject)] + return components.url + } + + static func makeStream(subject: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + guard let url = await makeURL(subject: subject) else { + continuation.finish(throwing: EffemKitConfigurationError.appViewHostNotConfigured) + return + } + var request = URLRequest(url: url) + if let token = await EffemEnvironment.current.readToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let session = URLSession(configuration: .default) + let socket = session.webSocketTask(with: request) + socket.resume() + defer { socket.cancel(with: .goingAway, reason: nil) } + + let decoder = JSONDecoder() + + while !Task.isCancelled { + do { + let message = try await socket.receive() + let data: Data + switch message { + case .data(let d): data = d + case .string(let s): + guard let d = s.data(using: .utf8) else { continue } + data = d + @unknown default: + continue + } + let event = try decoder.decode(CommentEvent.self, from: data) + continuation.yield(event) + } catch { + if !Task.isCancelled { + continuation.finish(throwing: error) + } + return + } + } + continuation.finish() + } + continuation.onTermination = { _ in + task.cancel() + } + } + } +} diff --git a/Sources/EffemKit/EffemAPI.swift b/Sources/EffemKit/EffemAPI.swift index 710dceb..e83e319 100644 --- a/Sources/EffemKit/EffemAPI.swift +++ b/Sources/EffemKit/EffemAPI.swift @@ -7,19 +7,36 @@ enum EffemAPI { // MARK: - Social Queries (indexed from firehose) case getSubscriptions(did: String, cursor: String?, limit: Int) - case getSubscribers(feedId: Int, cursor: String?, limit: Int) - case getComments(feedId: Int, episodeId: Int, cursor: String?, limit: Int) + case getSubscribers(subject: String, cursor: String?, limit: Int) + case getComments(subject: String, cursor: String?, limit: Int) case getCommentThread(uri: String) - case getRecommendations(feedId: Int, episodeId: Int, cursor: String?, limit: Int) + case getRecommendations(subject: String?, cursor: String?, limit: Int) case getPopular(period: String, limit: Int) case getList(uri: String) case getLists(did: String, cursor: String?, limit: Int) - case getBookmarks(did: String, feedId: Int?, episodeId: Int?, cursor: String?, limit: Int) - case getEpisodeStates(did: String, feedId: Int?, saved: Bool?, showHidden: Bool?, cursor: String?, limit: Int) + case getBookmarks(did: String, subject: String?, cursor: String?, limit: Int) + case getEpisodeStates(did: String, subject: String?, saved: Bool?, showHidden: Bool?, cursor: String?, limit: Int) case getInbox(did: String, cursor: String?, limit: Int) case getProfile(did: String) case getBlocks(did: String, cursor: String?, limit: Int) + // MARK: - Catalog + + case resolveEpisode(feedId: Int?, episodeId: Int?, podcastGuid: String?, episodeGuid: String?) + case resolvePodcast(feedId: Int?, podcastGuid: String?) + case getEpisodeRecord(uri: String) + case getPodcastRecord(uri: String) + case describeLexicons + + // MARK: - Notifications + + case listNotifications(limit: Int, cursor: String?, reasons: [String]?) + case updateSeen(seenAt: String) + case getPreferences + case setPreferences(preferences: [NotificationPreference]) + case registerDevice(token: String, environment: String, bundleId: String?) + case unregisterDevice(token: String) + // MARK: - Podcast Index Proxy (cached by AppView) case searchPodcasts(query: String, max: Int, searchType: String?) @@ -60,6 +77,19 @@ extension EffemAPI: EndpointType { case .getInbox: "/xrpc/xyz.effem.feed.getInbox" case .getProfile: "/xrpc/xyz.effem.actor.getProfile" case .getBlocks: "/xrpc/xyz.effem.feed.getBlocks" + // Catalog + case .resolveEpisode: "/xrpc/xyz.effem.feed.resolveEpisode" + case .resolvePodcast: "/xrpc/xyz.effem.feed.resolvePodcast" + case .getEpisodeRecord: "/xrpc/xyz.effem.feed.getEpisodeRecord" + case .getPodcastRecord: "/xrpc/xyz.effem.feed.getPodcastRecord" + case .describeLexicons: "/xrpc/xyz.effem.feed.describeLexicons" + // Notifications + case .listNotifications: "/xrpc/xyz.effem.notification.listNotifications" + case .updateSeen: "/xrpc/xyz.effem.notification.updateSeen" + case .getPreferences: "/xrpc/xyz.effem.notification.getPreferences" + case .setPreferences: "/xrpc/xyz.effem.notification.setPreferences" + case .registerDevice: "/xrpc/xyz.effem.notification.registerDevice" + case .unregisterDevice: "/xrpc/xyz.effem.notification.unregisterDevice" // Podcast Index proxy case .searchPodcasts: "/xrpc/xyz.effem.search.podcasts" case .searchEpisodes: "/xrpc/xyz.effem.search.episodes" @@ -73,7 +103,14 @@ extension EffemAPI: EndpointType { } } - var httpMethod: HTTPMethod { .get } + var httpMethod: HTTPMethod { + switch self { + case .updateSeen, .setPreferences, .registerDevice, .unregisterDevice: + return .post + default: + return .get + } + } var task: HTTPTask { switch self { @@ -84,21 +121,22 @@ extension EffemAPI: EndpointType { if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) - case .getSubscribers(let feedId, let cursor, let limit): - var params: Parameters = ["feedId": feedId, "limit": limit] + case .getSubscribers(let subject, let cursor, let limit): + var params: Parameters = ["subject": subject, "limit": limit] if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) - case .getComments(let feedId, let episodeId, let cursor, let limit): - var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit] + case .getComments(let subject, let cursor, let limit): + var params: Parameters = ["subject": subject, "limit": limit] if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) case .getCommentThread(let uri): return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri])) - case .getRecommendations(let feedId, let episodeId, let cursor, let limit): - var params: Parameters = ["feedId": feedId, "episodeId": episodeId, "limit": limit] + case .getRecommendations(let subject, let cursor, let limit): + var params: Parameters = ["limit": limit] + if let subject { params["subject"] = subject } if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) @@ -116,16 +154,15 @@ extension EffemAPI: EndpointType { if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) - case .getBookmarks(let did, let feedId, let episodeId, let cursor, let limit): + case .getBookmarks(let did, let subject, let cursor, let limit): var params: Parameters = ["did": did, "limit": limit] - if let feedId { params["feedId"] = feedId } - if let episodeId { params["episodeId"] = episodeId } + if let subject { params["subject"] = subject } if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) - case .getEpisodeStates(let did, let feedId, let saved, let showHidden, let cursor, let limit): + case .getEpisodeStates(let did, let subject, let saved, let showHidden, let cursor, let limit): var params: Parameters = ["did": did, "limit": limit] - if let feedId { params["feedId"] = feedId } + if let subject { params["subject"] = subject } if let saved, saved { params["saved"] = "true" } if let showHidden, showHidden { params["hidden"] = "true" } if let cursor { params["cursor"] = cursor } @@ -144,6 +181,58 @@ extension EffemAPI: EndpointType { if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) + // MARK: Catalog + + case .resolveEpisode(let feedId, let episodeId, let podcastGuid, let episodeGuid): + var params: Parameters = [:] + if let feedId { params["feedId"] = feedId } + if let episodeId { params["episodeId"] = episodeId } + if let podcastGuid { params["podcastGuid"] = podcastGuid } + if let episodeGuid { params["episodeGuid"] = episodeGuid } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .resolvePodcast(let feedId, let podcastGuid): + var params: Parameters = [:] + if let feedId { params["feedId"] = feedId } + if let podcastGuid { params["podcastGuid"] = podcastGuid } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getEpisodeRecord(let uri): + return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri])) + + case .getPodcastRecord(let uri): + return .requestParameters(encoding: .urlEncoding(parameters: ["uri": uri])) + + case .describeLexicons: + return .request + + // MARK: Notifications + + case .listNotifications(let limit, let cursor, let reasons): + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + if let reasons, !reasons.isEmpty { params["reasons"] = reasons } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .updateSeen(let seenAt): + let body: [String: Any] = ["seen_at": seenAt] + return jsonBody(body) + + case .getPreferences: + return .request + + case .setPreferences(let prefs): + let payload = prefs.map { ["reason": $0.reason.rawValue, "enabled": $0.enabled] } + return jsonBody(["preferences": payload]) + + case .registerDevice(let token, let environment, let bundleId): + var body: [String: Any] = ["token": token, "environment": environment] + if let bundleId { body["bundle_id"] = bundleId } + return jsonBody(body) + + case .unregisterDevice(let token): + return jsonBody(["token": token]) + // MARK: Podcast Index Proxy case .searchPodcasts(let query, let max, let searchType): @@ -187,4 +276,13 @@ extension EffemAPI: EndpointType { } var headers: HTTPHeaders? { nil } + + /// Encodes a JSON object as a request body. Used by the POST endpoints + /// where the wire format is application/json rather than a URL query. + private func jsonBody(_ body: [String: Any]) -> HTTPTask { + guard let data = try? JSONSerialization.data(withJSONObject: body) else { + return .request + } + return .requestParameters(encoding: .jsonDataEncoding(data: data)) + } } diff --git a/Sources/EffemKit/EffemRepoService.swift b/Sources/EffemKit/EffemRepoService.swift index 9115989..e60c08c 100644 --- a/Sources/EffemKit/EffemRepoService.swift +++ b/Sources/EffemKit/EffemRepoService.swift @@ -7,6 +7,12 @@ import NetworkingKit /// All write operations go to the user's PDS (not the AppView). /// The AppView indexes these records asynchronously via the firehose. /// +/// Every subject-bearing record (comment, recommendation, bookmark, +/// episode-state, subscription, commentLike) takes a ``StrongRef`` for its +/// `subject` field. Callers resolve a `StrongRef` via +/// ``EffemService/resolveEpisodeSubject(feedId:episodeId:)`` (or the +/// guid-based variant) before composing. +/// /// Requires the user to be authenticated via CoreATProtocol first. @APActor public struct EffemRepoService: Sendable { @@ -55,12 +61,12 @@ public struct EffemRepoService: Sendable { /// Creates a podcast subscription record in the user's AT Proto repo. public func subscribe( - to podcast: PodcastRef, + to subject: StrongRef, repo: String ) async throws -> CreateRecordResponse { let record: [String: Any] = [ "$type": "xyz.effem.feed.subscription", - "podcast": podcast.toRecord(), + "subject": subject.toRecord(), "createdAt": Self.now(), ] return try await createRecord( @@ -82,16 +88,22 @@ public struct EffemRepoService: Sendable { // MARK: - Comments /// Creates an episode comment record in the user's AT Proto repo. + /// + /// `selfLabels` attaches author-applied content-warning labels via + /// `com.atproto.label.defs#selfLabels`. The values become tap-to-reveal + /// gates on any compliant renderer; pass `nil` (the default) when the + /// comment doesn't need a warning. public func postComment( - episode: EpisodeRef, + subject: StrongRef, text: String, timestamp: Int? = nil, - reply: CommentReplyRef? = nil, + reply: Comment.Reply? = nil, + selfLabels: [SelfLabelValue]? = nil, repo: String ) async throws -> CreateRecordResponse { var record: [String: Any] = [ "$type": "xyz.effem.feed.comment", - "episode": episode.toRecord(), + "subject": subject.toRecord(), "text": text, "createdAt": Self.now(), ] @@ -99,10 +111,13 @@ public struct EffemRepoService: Sendable { record["timestamp"] = timestamp } if let reply { - record["reply"] = [ - "root": ["uri": reply.rootURI, "cid": reply.rootCID], - "parent": ["uri": reply.parentURI, "cid": reply.parentCID], - ] + record["reply"] = reply.toRecord() + } + if let selfLabels, !selfLabels.isEmpty { + record["labels"] = [ + "$type": "com.atproto.label.defs#selfLabels", + "values": selfLabels.map { $0.toRecord() }, + ] as [String: Any] } return try await createRecord( repo: repo, @@ -120,6 +135,86 @@ public struct EffemRepoService: Sendable { ) } + // MARK: - Threadgate + + /// Publishes or updates the threadgate for a root comment, restricting + /// who may reply under it. + /// + /// AT Protocol convention: the threadgate's rkey MUST match the root + /// comment's rkey, and the threadgate must be authored by the comment's + /// author. `rkey` here is the root comment's rkey; the AppView refuses + /// records that violate either rule. + /// + /// Semantics of `allow`: + /// - `nil` — no restriction; anyone may reply. + /// - `[]` — locked; only the root comment's author may reply. + /// - `[.mention]` — only DIDs @-mentioned in the root comment's facets + /// (plus the author) may reply. Other rule types are forward-compat + /// placeholders that the AppView does not enforce yet. + public func setThreadgate( + commentURI: String, + commentCID: String, + rkey: String, + allow: [ThreadgateAllowRule]?, + repo: String + ) async throws -> CreateRecordResponse { + var record: [String: Any] = [ + "$type": "xyz.effem.feed.threadgate", + "comment": ["uri": commentURI, "cid": commentCID], + "createdAt": Self.now(), + ] + if let allow { + record["allow"] = allow.map { $0.toRecord() } + } + let body: [String: Any] = [ + "repo": repo, + "collection": "xyz.effem.feed.threadgate", + "rkey": rkey, + "record": record, + ] + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.putRecord(body: data)) + } + + /// Deletes the threadgate for a root comment, restoring unrestricted + /// replies. `rkey` is the root comment's rkey (also the threadgate's + /// rkey, by convention). + public func deleteThreadgate(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.threadgate", + rkey: rkey + ) + } + + // MARK: - Comment Likes + + /// Creates a like on a comment. + public func likeComment( + subject: StrongRef, + repo: String + ) async throws -> CreateRecordResponse { + let record: [String: Any] = [ + "$type": "xyz.effem.feed.commentLike", + "subject": subject.toRecord(), + "createdAt": Self.now(), + ] + return try await createRecord( + repo: repo, + collection: "xyz.effem.feed.commentLike", + record: record + ) + } + + /// Removes a like on a comment. + public func unlikeComment(rkey: String, repo: String) async throws { + try await deleteRecord( + repo: repo, + collection: "xyz.effem.feed.commentLike", + rkey: rkey + ) + } + // MARK: - Blocks /// Creates a block record for a user in the authenticated user's AT Proto repo. @@ -177,13 +272,13 @@ public struct EffemRepoService: Sendable { /// Creates an episode recommendation record in the user's AT Proto repo. public func recommend( - episode: EpisodeRef, + subject: StrongRef, text: String? = nil, repo: String ) async throws -> CreateRecordResponse { var record: [String: Any] = [ "$type": "xyz.effem.feed.recommendation", - "subject": episode.toRecord(), + "subject": subject.toRecord(), "createdAt": Self.now(), ] if let text { @@ -209,13 +304,13 @@ public struct EffemRepoService: Sendable { /// Creates a public bookmark record in the user's AT Proto repo. public func bookmark( - episode: EpisodeRef, + subject: StrongRef, timestamp: Int? = nil, repo: String ) async throws -> CreateRecordResponse { var record: [String: Any] = [ "$type": "xyz.effem.feed.bookmark", - "episode": episode.toRecord(), + "subject": subject.toRecord(), "createdAt": Self.now(), ] if let timestamp { @@ -245,7 +340,8 @@ public struct EffemRepoService: Sendable { /// updates so the original creation timestamp is preserved; pass `nil` on /// the first write to use the current time. public func putEpisodeState( - episode: EpisodeRef, + subject: StrongRef, + rkey: String, positionS: Int? = nil, durationS: Int? = nil, played: Bool? = nil, @@ -257,7 +353,7 @@ public struct EffemRepoService: Sendable { let now = Self.now() var record: [String: Any] = [ "$type": "xyz.effem.feed.episodeState", - "episode": episode.toRecord(), + "subject": subject.toRecord(), "createdAt": createdAt ?? now, ] if let positionS { record["positionS"] = positionS } @@ -270,7 +366,7 @@ public struct EffemRepoService: Sendable { let body: [String: Any] = [ "repo": repo, "collection": "xyz.effem.feed.episodeState", - "rkey": String(episode.episodeId), + "rkey": rkey, "record": record, ] let data = try JSONSerialization.data(withJSONObject: body) @@ -292,7 +388,7 @@ public struct EffemRepoService: Sendable { public func createList( name: String, description: String? = nil, - podcasts: [PodcastRef], + podcasts: [StrongRef], repo: String ) async throws -> CreateRecordResponse { var record: [String: Any] = [ @@ -324,10 +420,16 @@ public struct EffemRepoService: Sendable { /// Creates or updates the user's Effem profile in their AT Proto repo. /// The profile uses the fixed record key `"self"`. + /// + /// `labelerSubscriptions` is a flat list of DIDs identifying labeler + /// services this user trusts; labels from those DIDs surface in the + /// AppView's read responses for this user. Effem's own labeler is + /// always trusted regardless of the value here. public func updateProfile( displayName: String? = nil, description: String? = nil, favoriteGenres: [String]? = nil, + labelerSubscriptions: [String]? = nil, repo: String ) async throws -> CreateRecordResponse { var profileRecord: [String: Any] = [ @@ -336,6 +438,7 @@ public struct EffemRepoService: Sendable { if let displayName { profileRecord["displayName"] = displayName } if let description { profileRecord["description"] = description } if let favoriteGenres { profileRecord["favoriteGenres"] = favoriteGenres } + if let labelerSubscriptions { profileRecord["labelerSubscriptions"] = labelerSubscriptions } let body: [String: Any] = [ "repo": repo, @@ -350,27 +453,12 @@ public struct EffemRepoService: Sendable { // MARK: - Helpers private static func now() -> String { - ISO8601DateFormatter().string(from: Date()) + ATDate.now() } } // MARK: - Supporting Types -/// Reference to parent/root comments for threading. -public struct CommentReplyRef: Sendable { - public let rootURI: String - public let rootCID: String - public let parentURI: String - public let parentCID: String - - public init(rootURI: String, rootCID: String, parentURI: String, parentCID: String) { - self.rootURI = rootURI - self.rootCID = rootCID - self.parentURI = parentURI - self.parentCID = parentCID - } -} - /// Errors specific to EffemRepoService operations. public enum EffemRepoError: Error, LocalizedError, Sendable { case invalidUri(String) diff --git a/Sources/EffemKit/EffemService.swift b/Sources/EffemKit/EffemService.swift index b227d5e..27f2999 100644 --- a/Sources/EffemKit/EffemService.swift +++ b/Sources/EffemKit/EffemService.swift @@ -4,8 +4,9 @@ import NetworkingKit /// Service for reading data from the Effem AppView. /// -/// Provides social queries (comments, recommendations, subscriptions) and -/// Podcast Index proxy endpoints (search, metadata) enriched with social overlays. +/// Provides social queries (comments, recommendations, subscriptions), +/// catalog resolution (episode/podcast strongRefs), and Podcast Index +/// proxy endpoints (search, metadata) enriched with social overlays. /// /// All endpoints are read-only GET requests against the AppView. /// For write operations, use ``EffemRepoService``. @@ -30,25 +31,25 @@ public struct EffemService: Sendable { try await execute(.getSubscriptions(did: did, cursor: cursor, limit: limit)) } - /// Returns users who subscribe to a given podcast. + /// Returns users who subscribe to a given podcast (identified by its + /// catalog AT-URI). public func getSubscribers( - feedId: Int, + subject: String, cursor: String? = nil, limit: Int = 50 ) async throws -> SubscribersResponse { - try await execute(.getSubscribers(feedId: feedId, cursor: cursor, limit: limit)) + try await execute(.getSubscribers(subject: subject, cursor: cursor, limit: limit)) } // MARK: - Comments - /// Returns comments for a specific episode. + /// Returns comments for an episode, identified by its catalog AT-URI. public func getComments( - feedId: Int, - episodeId: Int, + subject: String, cursor: String? = nil, limit: Int = 50 ) async throws -> CommentsResponse { - try await execute(.getComments(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) + try await execute(.getComments(subject: subject, cursor: cursor, limit: limit)) } /// Returns a threaded view of a comment and its replies. @@ -58,14 +59,13 @@ public struct EffemService: Sendable { // MARK: - Recommendations - /// Returns recommendations for a specific episode. + /// Returns recommendations for an episode, identified by its catalog AT-URI. public func getRecommendations( - feedId: Int, - episodeId: Int, + subject: String? = nil, cursor: String? = nil, limit: Int = 50 ) async throws -> RecommendationsResponse { - try await execute(.getRecommendations(feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) + try await execute(.getRecommendations(subject: subject, cursor: cursor, limit: limit)) } /// Returns the most recommended episodes over a given period. @@ -94,29 +94,31 @@ public struct EffemService: Sendable { // MARK: - Bookmarks - /// Returns a user's episode bookmarks, optionally filtered by podcast or episode. + /// Returns a user's episode bookmarks, optionally filtered to one episode. public func getBookmarks( did: String, - feedId: Int? = nil, - episodeId: Int? = nil, + subject: String? = nil, cursor: String? = nil, limit: Int = 50 ) async throws -> BookmarksResponse { - try await execute(.getBookmarks(did: did, feedId: feedId, episodeId: episodeId, cursor: cursor, limit: limit)) + try await execute(.getBookmarks(did: did, subject: subject, cursor: cursor, limit: limit)) } // MARK: - Episode States - /// Returns a user's episode states, optionally filtered by podcast, saved status, or hidden. + /// Returns a user's episode states, optionally filtered to one episode, + /// or by saved/hidden flags. public func getEpisodeStates( did: String, - feedId: Int? = nil, + subject: String? = nil, saved: Bool? = nil, showHidden: Bool? = nil, cursor: String? = nil, limit: Int = 50 ) async throws -> EpisodeStatesResponse { - try await execute(.getEpisodeStates(did: did, feedId: feedId, saved: saved, showHidden: showHidden, cursor: cursor, limit: limit)) + try await execute(.getEpisodeStates( + did: did, subject: subject, saved: saved, showHidden: showHidden, + cursor: cursor, limit: limit)) } // MARK: - Inbox @@ -148,6 +150,100 @@ public struct EffemService: Sendable { try await execute(.getBlocks(did: did, cursor: cursor, limit: limit)) } + // MARK: - Catalog + + /// Resolves a podcast episode to its canonical strongRef, fetching the + /// catalog record if the AppView hasn't seen the episode yet. Most + /// iOS callers pass Podcast Index IDs (the fast path); guid-only + /// resolution is supported for clients that already have them. + public func resolveEpisodeSubject( + feedId: Int? = nil, + episodeId: Int? = nil, + podcastGuid: String? = nil, + episodeGuid: String? = nil + ) async throws -> EpisodeResolution { + try await execute(.resolveEpisode( + feedId: feedId, episodeId: episodeId, + podcastGuid: podcastGuid, episodeGuid: episodeGuid)) + } + + /// Resolves a podcast to its canonical strongRef. + public func resolvePodcastSubject( + feedId: Int? = nil, + podcastGuid: String? = nil + ) async throws -> PodcastResolution { + try await execute(.resolvePodcast(feedId: feedId, podcastGuid: podcastGuid)) + } + + /// Dereferences an episode strongRef AT-URI to its canonical record. + public func getEpisodeRecord(uri: String) async throws -> EpisodeRecordResponse { + try await execute(.getEpisodeRecord(uri: uri)) + } + + public func getPodcastRecord(uri: String) async throws -> PodcastRecordResponse { + try await execute(.getPodcastRecord(uri: uri)) + } + + /// Returns every Effem-namespace lexicon known to the AppView so other + /// implementations can subscribe without out-of-band schema sharing. + public func describeLexicons() async throws -> LexiconsResponse { + try await execute(.describeLexicons) + } + + // MARK: - Notifications + + /// Returns notifications for the authenticated user, newest-first. + public func listNotifications( + limit: Int = 50, + cursor: String? = nil, + reasons: [NotificationReason]? = nil + ) async throws -> NotificationsResponse { + let reasonStrings = reasons?.map { $0.rawValue } + return try await execute(.listNotifications( + limit: limit, cursor: cursor, reasons: reasonStrings)) + } + + /// Advances the viewer's seen-watermark. Idempotent — older `seenAt` + /// values than what's stored are no-ops on the server side. + public func updateNotificationsSeen(seenAt: String) async throws { + let _: EmptyResponse = try await execute(.updateSeen(seenAt: seenAt)) + } + + /// Returns the authenticated user's per-reason notification preferences. + public func getNotificationPreferences() async throws -> NotificationPreferencesResponse { + try await execute(.getPreferences) + } + + /// Replaces the authenticated user's notification preferences. + public func setNotificationPreferences(_ preferences: [NotificationPreference]) async throws { + let _: EmptyResponse = try await execute(.setPreferences(preferences: preferences)) + } + + /// Registers an APNs device token for the authenticated user. + public func registerDevice( + token: String, + environment: APNsEnvironment, + bundleId: String? = nil + ) async throws { + let _: EmptyResponse = try await execute(.registerDevice( + token: token, environment: environment.rawValue, bundleId: bundleId)) + } + + /// Soft-deletes an APNs device token. + public func unregisterDevice(token: String) async throws { + let _: EmptyResponse = try await execute(.unregisterDevice(token: token)) + } + + // MARK: - Live Comment Stream + + /// Opens a websocket to `xyz.effem.feed.subscribeComments` and returns + /// an `AsyncThrowingStream` that yields one event per indexed + /// create/delete/like/unlike on the given subject. Cancelling the + /// stream's iterating task closes the connection. + public func subscribeComments(subject: String) -> AsyncThrowingStream { + CommentSubscription.makeStream(subject: subject) + } + // MARK: - Podcast Index Proxy /// Searches for podcasts via the AppView's Podcast Index proxy. @@ -213,3 +309,93 @@ public struct EffemService: Sendable { try await execute(.getStats) } } + +/// Response from `xyz.effem.feed.getEpisodeRecord`. Mirrors the shape of +/// `com.atproto.repo.getRecord` so verifying CIDs is a one-step affair. +public struct EpisodeRecordResponse: Codable, Sendable { + public let uri: String + public let cid: String + public let value: EpisodeRecord +} + +public struct PodcastRecordResponse: Codable, Sendable { + public let uri: String + public let cid: String + public let value: PodcastRecord +} + +/// Response from `xyz.effem.feed.describeLexicons`. Each entry carries the +/// raw JSON schema so consumers can validate records they encounter against +/// the publisher's authoritative spec. +public struct LexiconsResponse: Codable, Sendable { + public let lexicons: [LexiconDescriptor] +} + +public struct LexiconDescriptor: Codable, Sendable { + public let id: String + /// Encoded as raw JSON so the consumer doesn't need a typed mirror of + /// every lexicon schema field to deserialise. + public let schema: AnyJSON +} + +/// Type-erased JSON wrapper for free-form lexicon schemas. +public struct AnyJSON: Codable, Sendable { + public let data: Data + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(JSONValue.self) + self.data = try JSONEncoder().encode(value) + } + + public func encode(to encoder: Encoder) throws { + let value = try JSONDecoder().decode(JSONValue.self, from: data) + try value.encode(to: encoder) + } +} + +private enum JSONValue: Codable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let i = try? container.decode(Int.self) { + self = .int(i) + } else if let d = try? container.decode(Double.self) { + self = .double(d) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let a = try? container.decode([JSONValue].self) { + self = .array(a) + } else if let o = try? container.decode([String: JSONValue].self) { + self = .object(o) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode JSON value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let v): try container.encode(v) + case .int(let v): try container.encode(v) + case .double(let v): try container.encode(v) + case .string(let v): try container.encode(v) + case .array(let v): try container.encode(v) + case .object(let v): try container.encode(v) + } + } +} diff --git a/Sources/EffemKit/Models/Bookmark.swift b/Sources/EffemKit/Models/Bookmark.swift index 5027831..cfd7fe8 100644 --- a/Sources/EffemKit/Models/Bookmark.swift +++ b/Sources/EffemKit/Models/Bookmark.swift @@ -4,7 +4,7 @@ import Foundation public struct Bookmark: Codable, Sendable, Identifiable { public let did: String public let rkey: String - public let episode: EpisodeRef + public let subject: StrongRef public let timestamp: Int? public let createdAt: String diff --git a/Sources/EffemKit/Models/Comment.swift b/Sources/EffemKit/Models/Comment.swift index 784326d..6873877 100644 --- a/Sources/EffemKit/Models/Comment.swift +++ b/Sources/EffemKit/Models/Comment.swift @@ -5,47 +5,89 @@ public struct Comment: Codable, Sendable, Identifiable { public let did: String public let rkey: String public let cid: String? - public let episode: EpisodeRef + public let uri: String + public let subject: StrongRef public let text: String public let timestamp: Int? - public let replyRoot: String? - public let replyRootCid: String? - public let replyParent: String? - public let replyParentCid: String? + public let reply: Reply? + public let selfLabels: [String]? + public let labels: [ModerationLabel]? public let createdAt: String public let author: CommentAuthor? + public let likeCount: Int + public let viewerLikeUri: String? public var id: String { "\(did)/\(rkey)" } - /// AT URI for this comment record. - public var uri: String { "at://\(did)/xyz.effem.feed.comment/\(rkey)" } + public struct Reply: Codable, Sendable, Hashable { + public let root: StrongRef + public let parent: StrongRef + + public init(root: StrongRef, parent: StrongRef) { + self.root = root + self.parent = parent + } + + /// Wire representation for posting a reply via `EffemRepoService.postComment`. + public func toRecord() -> [String: Any] { + [ + "root": root.toRecord(), + "parent": parent.toRecord(), + ] + } + } public init( did: String, rkey: String, cid: String? = nil, - episode: EpisodeRef, + uri: String, + subject: StrongRef, text: String, timestamp: Int? = nil, - replyRoot: String? = nil, - replyRootCid: String? = nil, - replyParent: String? = nil, - replyParentCid: String? = nil, + reply: Reply? = nil, + selfLabels: [String]? = nil, + labels: [ModerationLabel]? = nil, createdAt: String, - author: CommentAuthor? = nil + author: CommentAuthor? = nil, + likeCount: Int = 0, + viewerLikeUri: String? = nil ) { self.did = did self.rkey = rkey self.cid = cid - self.episode = episode + self.uri = uri + self.subject = subject self.text = text self.timestamp = timestamp - self.replyRoot = replyRoot - self.replyRootCid = replyRootCid - self.replyParent = replyParent - self.replyParentCid = replyParentCid + self.reply = reply + self.selfLabels = selfLabels + self.labels = labels self.createdAt = createdAt self.author = author + self.likeCount = likeCount + self.viewerLikeUri = viewerLikeUri + } + + /// True when the comment carries a moderation label asking the + /// renderer to drop it. The AppView already filters `!hide`-labeled + /// rows out of public reads, but clients should still respect this + /// flag in case a label arrives via a path that bypasses the filter. + public var isHidden: Bool { + labels?.contains(where: { $0.isHide }) ?? false + } + + /// True when the comment should render behind a tap-to-reveal + /// content warning, either because the author self-labeled it or + /// because a labeler emitted a `!warn` directive. + public var needsContentWarning: Bool { + if let selfLabels, !selfLabels.isEmpty { return true } + return labels?.contains(where: { $0.isWarn }) ?? false + } + + /// Reply reference for retrying a reply, or `nil` for top-level comments. + public func toReply() -> Reply? { + reply } } @@ -66,6 +108,11 @@ public struct CommentsResponse: Codable, Sendable { /// Response from `xyz.effem.feed.getCommentThread`. public struct CommentThreadResponse: Codable, Sendable { public let thread: CommentThreadNode + public let replyCount: Int? + public let visibleReplyCount: Int? + /// Reply-restriction state for this thread. Nil means no threadgate + /// was published, which is equivalent to "anyone may reply". + public let threadSettings: ThreadSettings? } /// A node in a comment thread tree. diff --git a/Sources/EffemKit/Models/CommentEvent.swift b/Sources/EffemKit/Models/CommentEvent.swift new file mode 100644 index 0000000..5fc087e --- /dev/null +++ b/Sources/EffemKit/Models/CommentEvent.swift @@ -0,0 +1,46 @@ +import Foundation + +/// One frame on the `xyz.effem.feed.subscribeComments` websocket. Mirrors +/// the lexicon's `message` union: the wire payload is a tagged object +/// `{"type": "...", ...}` and this enum lifts the cases into native Swift. +public enum CommentEvent: Sendable { + case created(Comment) + case deleted(uri: String) + case liked(commentUri: String, likeCount: Int, actorDid: String?) + case unliked(commentUri: String, likeCount: Int, actorDid: String?) +} + +extension CommentEvent: Decodable { + enum CodingKeys: String, CodingKey { + case type + case comment + case uri + case commentUri + case likeCount + case actorDid + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "created": + let comment = try container.decode(Comment.self, forKey: .comment) + self = .created(comment) + case "deleted": + let uri = try container.decode(String.self, forKey: .uri) + self = .deleted(uri: uri) + case "liked", "unliked": + let commentUri = try container.decode(String.self, forKey: .commentUri) + let likeCount = try container.decode(Int.self, forKey: .likeCount) + let actor = try container.decodeIfPresent(String.self, forKey: .actorDid) + self = (type == "liked") + ? .liked(commentUri: commentUri, likeCount: likeCount, actorDid: actor) + : .unliked(commentUri: commentUri, likeCount: likeCount, actorDid: actor) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, + debugDescription: "Unknown CommentEvent type: \(type)") + } + } +} diff --git a/Sources/EffemKit/Models/EffemProfile.swift b/Sources/EffemKit/Models/EffemProfile.swift index 0782041..7b1a070 100644 --- a/Sources/EffemKit/Models/EffemProfile.swift +++ b/Sources/EffemKit/Models/EffemProfile.swift @@ -8,6 +8,7 @@ public struct EffemProfile: Codable, Sendable, Identifiable { public let description: String? public let avatar: String? public let favoriteGenres: [String]? + public let labelerSubscriptions: [String]? public let subscriptionCount: Int? public let commentCount: Int? public let recommendationCount: Int? diff --git a/Sources/EffemKit/Models/EpisodeState.swift b/Sources/EffemKit/Models/EpisodeState.swift index 8cee361..e3c9fc1 100644 --- a/Sources/EffemKit/Models/EpisodeState.swift +++ b/Sources/EffemKit/Models/EpisodeState.swift @@ -5,10 +5,7 @@ import Foundation public struct EpisodeState: Codable, Sendable, Identifiable { public let did: String public let rkey: String - public let feedId: Int - public let episodeId: Int - public let episodeGuid: String? - public let podcastGuid: String? + public let subject: StrongRef public let positionS: Int? public let durationS: Int? public let played: Bool @@ -20,10 +17,6 @@ public struct EpisodeState: Codable, Sendable, Identifiable { public var id: String { "\(did)/\(rkey)" } public var uri: String { "at://\(did)/xyz.effem.feed.episodeState/\(rkey)" } - - public var episode: EpisodeRef { - EpisodeRef(feedId: feedId, episodeId: episodeId, episodeGuid: episodeGuid, podcastGuid: podcastGuid) - } } /// Response from `xyz.effem.feed.getEpisodeStates`. diff --git a/Sources/EffemKit/Models/Moderation.swift b/Sources/EffemKit/Models/Moderation.swift new file mode 100644 index 0000000..145351c --- /dev/null +++ b/Sources/EffemKit/Models/Moderation.swift @@ -0,0 +1,166 @@ +import Foundation + +/// One moderation label attached to a subject record. Labels are emitted +/// by labelers (DIDs that run a labeling service) and indexed by the +/// AppView; effem's own AppView emits labels with its labeler DID, and +/// the AppView additionally imports labels from external labelers the +/// viewer has subscribed to. +/// +/// Conventional values: +/// - `!hide` — renderer should drop the comment entirely. +/// - `!warn` — renderer should gate the content behind a tap-to-reveal. +/// - free-form values like `spam`, `abuse` — render as descriptive tags +/// so the user can decide whether to act. +public struct ModerationLabel: Codable, Sendable, Hashable, Identifiable { + public let id: Int + public let src: String + public let subjectUri: String + public let val: String + public let neg: Bool + public let createdAt: String + + public init(id: Int, src: String, subjectUri: String, val: String, neg: Bool, createdAt: String) { + self.id = id + self.src = src + self.subjectUri = subjectUri + self.val = val + self.neg = neg + self.createdAt = createdAt + } + + /// True when the labeler asked the renderer to drop this subject. The + /// AppView already filters `!hide`-labeled comments out of public read + /// responses, but clients should treat the directive as load-bearing + /// in case a label arrives via a route that bypasses the server filter. + public var isHide: Bool { val == "!hide" } + + /// True when the labeler asked the renderer to gate this subject + /// behind a content warning. + public var isWarn: Bool { val == "!warn" } +} + +/// Response shape from `xyz.effem.admin.listLabels`. +public struct LabelsResponse: Codable, Sendable { + public let labels: [ModerationLabel] + public let cursor: String? +} + +/// One rule inside a threadgate's `allow` array. +/// +/// The wire format is `{"$type": "xyz.effem.feed.threadgate#mentionRule"}`. +/// Decoding into a discriminated enum lets call sites pattern-match without +/// dealing with raw type strings; unknown rules round-trip as +/// `.unknown("xyz.effem.feed.threadgate#fooRule")` so older clients can at +/// least see what they don't yet support. +public enum ThreadgateAllowRule: Codable, Sendable, Hashable { + case mention + case unknown(String) + + private enum CodingKeys: String, CodingKey { + case type = "$type" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "xyz.effem.feed.threadgate#mentionRule": + self = .mention + default: + self = .unknown(type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(typeString, forKey: .type) + } + + public var typeString: String { + switch self { + case .mention: "xyz.effem.feed.threadgate#mentionRule" + case .unknown(let s): s + } + } + + public func toRecord() -> [String: Any] { + ["$type": typeString] + } +} + +/// Per-thread reply restriction state as returned on +/// `xyz.effem.feed.getCommentThread`. A missing response field (i.e. the +/// AppView omits `thread_settings` entirely) means "no threadgate" — +/// anyone may reply. +/// +/// Field semantics: +/// - `allowSet == false` → the threadgate record exists but `allow` was +/// omitted; equivalent to "anyone may reply". +/// - `allowSet && allow == []` → the thread is locked; only the root +/// comment's author may reply. +/// - `allowSet && allow != []` → restricted; replies must satisfy at +/// least one rule (or be the root author). +public struct ThreadSettings: Codable, Sendable, Hashable { + public let rkey: String? + public let allowSet: Bool + public let allow: [ThreadgateAllowRule]? + + public init(rkey: String?, allowSet: Bool, allow: [ThreadgateAllowRule]?) { + self.rkey = rkey + self.allowSet = allowSet + self.allow = allow + } + + private enum CodingKeys: String, CodingKey { + case rkey, allow + case allowSet = "allow_set" + } + + /// True when replies are unrestricted (no record, or record with no + /// allow field). Default fallback for the UI's "Reply" affordance. + public var isOpen: Bool { !allowSet } + + /// True when replies are forbidden except for the root comment's + /// author. + public var isLocked: Bool { + allowSet && (allow?.isEmpty ?? true) + } + + /// True when the only rule in effect is `mentionRule`, the most + /// common "limit replies" mode in iOS today. + public var isMentionOnly: Bool { + guard allowSet, let rules = allow, !rules.isEmpty else { return false } + return rules.allSatisfy { rule in + if case .mention = rule { return true } + return false + } + } +} + +/// Author-applied content warning values. The composer writes the picked +/// subset into the comment's `labels` field as +/// `com.atproto.label.defs#selfLabels`. +/// +/// Conventional values match Bluesky's set so a renderer that already +/// handles their labels can render effem comments without per-app +/// vocabulary mapping. +public enum SelfLabelValue: String, CaseIterable, Sendable, Codable { + case sexual + case nudity + case graphicMedia = "graphic-media" + case other + + public var displayName: String { + switch self { + case .sexual: "Sexual" + case .nudity: "Nudity" + case .graphicMedia: "Graphic media" + case .other: "Other" + } + } + + /// Wire shape for one entry inside `com.atproto.label.defs#selfLabels`. + public func toRecord() -> [String: Any] { + ["val": rawValue] + } +} diff --git a/Sources/EffemKit/Models/Notification.swift b/Sources/EffemKit/Models/Notification.swift new file mode 100644 index 0000000..a7d83c2 --- /dev/null +++ b/Sources/EffemKit/Models/Notification.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Reason a notification was generated. Mirrors the AppView's knownValues. +public enum NotificationReason: String, Codable, Sendable, CaseIterable { + case reply + case mention + case like +} + +/// One row from `xyz.effem.notification.listNotifications`. +public struct EffemNotification: Codable, Sendable, Identifiable { + public let id: Int + public let reason: NotificationReason + public let actorDid: String + public let subjectUri: String + public let sourceUri: String + public let createdAt: String + public let isRead: Bool + public let actor: CommentAuthor? +} + +/// Response from `xyz.effem.notification.listNotifications`. +public struct NotificationsResponse: Codable, Sendable { + public let notifications: [EffemNotification] + public let cursor: String? + public let seenAt: String? +} + +/// One row from `xyz.effem.notification.getPreferences`. +public struct NotificationPreference: Codable, Sendable, Identifiable { + public let reason: NotificationReason + public let enabled: Bool + + public var id: NotificationReason { reason } + + public init(reason: NotificationReason, enabled: Bool) { + self.reason = reason + self.enabled = enabled + } +} + +public struct NotificationPreferencesResponse: Codable, Sendable { + public let preferences: [NotificationPreference] +} + +/// Environment passed to `xyz.effem.notification.registerDevice`. Matches +/// the APNs concept of sandbox vs. production. +public enum APNsEnvironment: String, Codable, Sendable { + case sandbox + case production +} diff --git a/Sources/EffemKit/Models/PodcastList.swift b/Sources/EffemKit/Models/PodcastList.swift index 439d39c..bad84ad 100644 --- a/Sources/EffemKit/Models/PodcastList.swift +++ b/Sources/EffemKit/Models/PodcastList.swift @@ -6,7 +6,7 @@ public struct PodcastList: Codable, Sendable, Identifiable { public let rkey: String public let name: String public let description: String? - public let podcasts: [PodcastRef] + public let podcasts: [StrongRef] public let createdAt: String public let author: CommentAuthor? diff --git a/Sources/EffemKit/Models/Recommendation.swift b/Sources/EffemKit/Models/Recommendation.swift index bdb06d9..573c12c 100644 --- a/Sources/EffemKit/Models/Recommendation.swift +++ b/Sources/EffemKit/Models/Recommendation.swift @@ -4,7 +4,7 @@ import Foundation public struct Recommendation: Codable, Sendable, Identifiable { public let did: String public let rkey: String - public let episode: EpisodeRef + public let subject: StrongRef public let text: String? public let createdAt: String public let author: CommentAuthor? @@ -13,6 +13,22 @@ public struct Recommendation: Codable, Sendable, Identifiable { /// AT URI for this recommendation record. public var uri: String { "at://\(did)/xyz.effem.feed.recommendation/\(rkey)" } + + public init( + did: String, + rkey: String, + subject: StrongRef, + text: String? = nil, + createdAt: String, + author: CommentAuthor? = nil + ) { + self.did = did + self.rkey = rkey + self.subject = subject + self.text = text + self.createdAt = createdAt + self.author = author + } } /// Response from `xyz.effem.feed.getRecommendations`. @@ -21,19 +37,16 @@ public struct RecommendationsResponse: Codable, Sendable { public let cursor: String? } -/// A popular episode with its recommendation count. +/// A popular episode entry; the catalog AT-URI plus the recommendation +/// tally over the selected period. public struct PopularEpisode: Codable, Sendable, Identifiable { - public let episode: EpisodeRef - public let recommendationCount: Int - public let title: String? - public let podcastTitle: String? - public let image: String? + public let subjectUri: String + public let count: Int - public var id: Int { episode.episodeId } + public var id: String { subjectUri } } /// Response from `xyz.effem.feed.getPopular`. public struct PopularResponse: Codable, Sendable { - public let episodes: [PopularEpisode] - public let cursor: String? + public let items: [PopularEpisode] } diff --git a/Sources/EffemKit/Models/StrongRef.swift b/Sources/EffemKit/Models/StrongRef.swift new file mode 100644 index 0000000..5b1d0fc --- /dev/null +++ b/Sources/EffemKit/Models/StrongRef.swift @@ -0,0 +1,72 @@ +import Foundation + +/// AT Protocol `com.atproto.repo.strongRef` — an AT URI paired with the +/// content-hash CID of the record it points at. +/// +/// Used as the `subject` of every social record (comment, recommendation, +/// bookmark, episode-state, subscription, commentLike). The pair is +/// content-addressable: any party that sees a strongRef can fetch the +/// record and verify the CID matches the canonical CBOR encoding of the +/// returned value. +public struct StrongRef: Codable, Sendable, Hashable { + public let uri: String + public let cid: String + + public init(uri: String, cid: String) { + self.uri = uri + self.cid = cid + } + + /// Wire representation used when posting records to a PDS. Matches the + /// AT Proto record shape `{uri, cid}` exactly. + public func toRecord() -> [String: Any] { + ["uri": uri, "cid": cid] + } +} + +/// Episode catalog record returned by `xyz.effem.feed.getEpisodeRecord`. +/// Mirrors the canonical `xyz.effem.feed.episode` lexicon. +public struct EpisodeRecord: Codable, Sendable { + public let podcastGuid: String + public let episodeGuid: String + public let title: String + public let publishedAt: String + public let feedUrl: String? + public let enclosureUrl: String? + public let durationS: Int? + public let podcastIndex: PodcastIndexRef? + + public struct PodcastIndexRef: Codable, Sendable { + public let feedId: Int? + public let episodeId: Int? + } +} + +/// Podcast catalog record returned by `xyz.effem.feed.getPodcastRecord`. +public struct PodcastRecord: Codable, Sendable { + public let podcastGuid: String + public let title: String + public let feedUrl: String? + public let author: String? + public let artworkUrl: String? + public let language: String? + public let podcastIndex: PodcastIndexRef? + + public struct PodcastIndexRef: Codable, Sendable { + public let feedId: Int? + } +} + +/// Response envelope from `xyz.effem.feed.resolveEpisode` / +/// `resolvePodcast`. Combines the strongRef clients will embed with the +/// inline record so callers can show the title/artwork without a second +/// round-trip. +public struct EpisodeResolution: Codable, Sendable { + public let subject: StrongRef + public let record: EpisodeRecord +} + +public struct PodcastResolution: Codable, Sendable { + public let subject: StrongRef + public let record: PodcastRecord +} diff --git a/Sources/EffemKit/Models/Subscription.swift b/Sources/EffemKit/Models/Subscription.swift index ca97c32..2e1177b 100644 --- a/Sources/EffemKit/Models/Subscription.swift +++ b/Sources/EffemKit/Models/Subscription.swift @@ -4,7 +4,7 @@ import Foundation public struct Subscription: Codable, Sendable, Identifiable { public let did: String public let rkey: String - public let podcast: PodcastRef + public let subject: StrongRef public let createdAt: String public var id: String { "\(did)/\(rkey)" } @@ -21,15 +21,17 @@ public struct SubscriptionsResponse: Codable, Sendable { /// Response from `xyz.effem.feed.getSubscribers`. public struct SubscribersResponse: Codable, Sendable { + public let subject: String? public let subscribers: [SubscriberInfo] + public let count: Int? public let cursor: String? } /// A user who subscribes to a podcast. public struct SubscriberInfo: Codable, Sendable, Identifiable { public let did: String - public let displayName: String? - public let avatar: String? + public let rkey: String? + public let createdAt: String? public var id: String { did } } diff --git a/Tests/EffemKitTests/EffemKitTests.swift b/Tests/EffemKitTests/EffemKitTests.swift index 800de23..80d675e 100644 --- a/Tests/EffemKitTests/EffemKitTests.swift +++ b/Tests/EffemKitTests/EffemKitTests.swift @@ -2,90 +2,30 @@ import Testing import Foundation @testable import EffemKit -@Suite("PodcastRef") -struct PodcastRefTests { +@Suite("StrongRef") +struct StrongRefTests { @Test func decodesFromJSON() throws { let json = """ - {"feedId": 75075, "feedUrl": "https://example.com/feed.xml", "podcastGuid": "917393e3-1b1e-5cef-ace4-edaa54e1f810"} + {"uri": "at://did:plc:catalog/xyz.effem.feed.episode/abc", "cid": "bafyreig123"} """.data(using: .utf8)! - let ref = try JSONDecoder().decode(PodcastRef.self, from: json) - #expect(ref.feedId == 75075) - #expect(ref.feedUrl == "https://example.com/feed.xml") - #expect(ref.podcastGuid == "917393e3-1b1e-5cef-ace4-edaa54e1f810") + let ref = try JSONDecoder().decode(StrongRef.self, from: json) + #expect(ref.uri == "at://did:plc:catalog/xyz.effem.feed.episode/abc") + #expect(ref.cid == "bafyreig123") } - @Test func decodesWithMinimalFields() throws { - let json = """ - {"feedId": 42} - """.data(using: .utf8)! - - let ref = try JSONDecoder().decode(PodcastRef.self, from: json) - #expect(ref.feedId == 42) - #expect(ref.feedUrl == nil) - #expect(ref.podcastGuid == nil) - } - - @Test func roundTrips() throws { - let ref = PodcastRef(feedId: 100, feedUrl: "https://example.com/rss", podcastGuid: "abc-123") - let data = try JSONEncoder().encode(ref) - let decoded = try JSONDecoder().decode(PodcastRef.self, from: data) - #expect(ref == decoded) + @Test func toRecordRoundTrip() { + let ref = StrongRef(uri: "at://x/y/z", cid: "bafy123") + let dict = ref.toRecord() + #expect(dict["uri"] as? String == "at://x/y/z") + #expect(dict["cid"] as? String == "bafy123") } - @Test func toRecordIncludesOnlyNonNilFields() { - let minimal = PodcastRef(feedId: 1) - let record = minimal.toRecord() - #expect(record["feedId"] as? Int == 1) - #expect(record["feedUrl"] == nil) - #expect(record["podcastGuid"] == nil) - - let full = PodcastRef(feedId: 2, feedUrl: "https://x.com/rss", podcastGuid: "guid") - let fullRecord = full.toRecord() - #expect(fullRecord["feedId"] as? Int == 2) - #expect(fullRecord["feedUrl"] as? String == "https://x.com/rss") - #expect(fullRecord["podcastGuid"] as? String == "guid") - } -} - -@Suite("EpisodeRef") -struct EpisodeRefTests { - @Test func decodesFromJSON() throws { - let json = """ - {"feedId": 75075, "episodeId": 1721351091, "episodeGuid": "ac34129c", "podcastGuid": "917393e3"} - """.data(using: .utf8)! - - let ref = try JSONDecoder().decode(EpisodeRef.self, from: json) - #expect(ref.feedId == 75075) - #expect(ref.episodeId == 1721351091) - #expect(ref.episodeGuid == "ac34129c") - #expect(ref.podcastGuid == "917393e3") - } - - @Test func decodesWithMinimalFields() throws { - let json = """ - {"feedId": 10, "episodeId": 20} - """.data(using: .utf8)! - - let ref = try JSONDecoder().decode(EpisodeRef.self, from: json) - #expect(ref.feedId == 10) - #expect(ref.episodeId == 20) - #expect(ref.episodeGuid == nil) - } - - @Test func roundTrips() throws { - let ref = EpisodeRef(feedId: 5, episodeId: 10, episodeGuid: "ep-guid") - let data = try JSONEncoder().encode(ref) - let decoded = try JSONDecoder().decode(EpisodeRef.self, from: data) - #expect(ref == decoded) - } - - @Test func toRecordIncludesOnlyNonNilFields() { - let minimal = EpisodeRef(feedId: 1, episodeId: 2) - let record = minimal.toRecord() - #expect(record["feedId"] as? Int == 1) - #expect(record["episodeId"] as? Int == 2) - #expect(record["episodeGuid"] == nil) + @Test func hashable() { + let a = StrongRef(uri: "at://x/y/z", cid: "bafy") + let b = StrongRef(uri: "at://x/y/z", cid: "bafy") + #expect(a == b) + #expect(a.hashValue == b.hashValue) } } @@ -96,10 +36,17 @@ struct CommentTests { { "did": "did:plc:abc123", "rkey": "3jm2szx5c47mo", - "episode": {"feedId": 75075, "episodeId": 100}, + "cid": "bafyrei-self", + "uri": "at://did:plc:abc123/xyz.effem.feed.comment/3jm2szx5c47mo", + "subject": { + "uri": "at://did:plc:catalog/xyz.effem.feed.episode/ep1", + "cid": "bafyrei-ep1" + }, "text": "Great episode!", "timestamp": 120, - "createdAt": "2026-01-15T10:30:00Z", + "createdAt": "2026-01-15T10:30:00.000Z", + "like_count": 4, + "viewer_like_uri": "at://did:plc:me/xyz.effem.feed.commentLike/lk1", "author": { "did": "did:plc:abc123", "handle": "alice.bsky.social", @@ -108,14 +55,66 @@ struct CommentTests { } """.data(using: .utf8)! - let comment = try JSONDecoder().decode(EffemKit.Comment.self, from: json) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let comment = try decoder.decode(EffemKit.Comment.self, from: json) #expect(comment.did == "did:plc:abc123") #expect(comment.text == "Great episode!") #expect(comment.timestamp == 120) - #expect(comment.episode.feedId == 75075) + #expect(comment.subject.uri == "at://did:plc:catalog/xyz.effem.feed.episode/ep1") + #expect(comment.subject.cid == "bafyrei-ep1") #expect(comment.author?.displayName == "Alice") + #expect(comment.likeCount == 4) + #expect(comment.viewerLikeUri == "at://did:plc:me/xyz.effem.feed.commentLike/lk1") #expect(comment.uri == "at://did:plc:abc123/xyz.effem.feed.comment/3jm2szx5c47mo") } + + @Test func replyDecodesNested() throws { + let json = """ + { + "did": "did:plc:reply", + "rkey": "r1", + "uri": "at://did:plc:reply/xyz.effem.feed.comment/r1", + "subject": {"uri": "at://catalog/ep/1", "cid": "cid-ep"}, + "text": "I agree", + "createdAt": "2026-01-15T10:30:00.000Z", + "like_count": 0, + "reply": { + "root": {"uri": "at://x/root", "cid": "cid-root"}, + "parent": {"uri": "at://x/parent", "cid": "cid-parent"} + } + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let comment = try decoder.decode(EffemKit.Comment.self, from: json) + #expect(comment.reply?.root.uri == "at://x/root") + #expect(comment.reply?.parent.cid == "cid-parent") + } + + @Test func toReplyReturnsNilForTopLevel() { + let c = Comment( + did: "did:plc:x", rkey: "r", + uri: "at://did:plc:x/xyz.effem.feed.comment/r", + subject: StrongRef(uri: "at://catalog/ep", cid: "cid"), + text: "hi", createdAt: "2026-05-15T10:00:00.000Z") + #expect(c.toReply() == nil) + } + + @Test func toReplyReturnsRefForReply() { + let root = StrongRef(uri: "at://root", cid: "rc") + let parent = StrongRef(uri: "at://parent", cid: "pc") + let c = Comment( + did: "did:plc:x", rkey: "r", + uri: "at://did:plc:x/xyz.effem.feed.comment/r", + subject: StrongRef(uri: "at://catalog/ep", cid: "cid"), + text: "hi", reply: Comment.Reply(root: root, parent: parent), + createdAt: "2026-05-15T10:00:00.000Z") + let reply = c.toReply() + #expect(reply?.root == root) + #expect(reply?.parent == parent) + } } @Suite("Subscription") @@ -125,14 +124,59 @@ struct SubscriptionTests { { "did": "did:plc:xyz", "rkey": "abc123", - "podcast": {"feedId": 42}, - "createdAt": "2026-01-01T00:00:00Z" + "subject": {"uri": "at://catalog/pod/1", "cid": "cid-pod"}, + "createdAt": "2026-01-01T00:00:00.000Z" } """.data(using: .utf8)! let sub = try JSONDecoder().decode(Subscription.self, from: json) #expect(sub.uri == "at://did:plc:xyz/xyz.effem.feed.subscription/abc123") #expect(sub.id == "did:plc:xyz/abc123") + #expect(sub.subject.uri == "at://catalog/pod/1") + } +} + +@Suite("Bookmark") +struct BookmarkTests { + @Test func decodesFromJSON() throws { + let json = """ + { + "did": "did:plc:xyz", + "rkey": "tid123", + "subject": {"uri": "at://catalog/ep/2", "cid": "cid-ep"}, + "timestamp": 300, + "createdAt": "2026-02-01T00:00:00.000Z" + } + """.data(using: .utf8)! + + let bookmark = try JSONDecoder().decode(Bookmark.self, from: json) + #expect(bookmark.timestamp == 300) + #expect(bookmark.uri == "at://did:plc:xyz/xyz.effem.feed.bookmark/tid123") + #expect(bookmark.subject.cid == "cid-ep") + } +} + +@Suite("PodcastList") +struct PodcastListTests { + @Test func decodesFromJSON() throws { + let json = """ + { + "did": "did:plc:xyz", + "rkey": "list1", + "name": "My Favorite Pods", + "description": "The best ones", + "podcasts": [ + {"uri": "at://catalog/p/1", "cid": "cid-1"}, + {"uri": "at://catalog/p/2", "cid": "cid-2"} + ], + "createdAt": "2026-01-01T00:00:00.000Z" + } + """.data(using: .utf8)! + + let list = try JSONDecoder().decode(PodcastList.self, from: json) + #expect(list.name == "My Favorite Pods") + #expect(list.podcasts.count == 2) + #expect(list.podcasts[1].uri == "at://catalog/p/2") } } @@ -150,74 +194,287 @@ struct SocialOverlayTests { #expect(overlay.bookmarkCount == nil) #expect(overlay.subscribedByFollowing == nil) } +} - @Test func decodesFullFields() throws { +@Suite("EpisodeRecord") +struct EpisodeRecordTests { + @Test func decodesFromJSON() throws { let json = """ { - "subscriberCount": 100, - "commentCount": 50, - "recommendationCount": 25, - "bookmarkCount": 10, - "subscribedByFollowing": ["did:plc:a", "did:plc:b"] + "podcastGuid": "pg-1", + "episodeGuid": "eg-1", + "title": "Episode 1", + "publishedAt": "2026-01-01T00:00:00.000Z", + "feedUrl": "https://example.com/rss", + "durationS": 1800, + "podcastIndex": {"feedId": 75075, "episodeId": 1234567} } """.data(using: .utf8)! - let overlay = try JSONDecoder().decode(SocialOverlay.self, from: json) - #expect(overlay.subscriberCount == 100) - #expect(overlay.subscribedByFollowing?.count == 2) + let rec = try JSONDecoder().decode(EpisodeRecord.self, from: json) + #expect(rec.podcastGuid == "pg-1") + #expect(rec.episodeGuid == "eg-1") + #expect(rec.title == "Episode 1") + #expect(rec.durationS == 1800) + #expect(rec.podcastIndex?.feedId == 75075) + #expect(rec.podcastIndex?.episodeId == 1234567) } } -@Suite("Bookmark") -struct BookmarkTests { - @Test func decodesFromJSON() throws { +@Suite("CommentEvent") +struct CommentEventTests { + @Test func decodesCreated() throws { let json = """ { - "did": "did:plc:xyz", - "rkey": "tid123", - "episode": {"feedId": 1, "episodeId": 2}, - "timestamp": 300, - "createdAt": "2026-02-01T00:00:00Z" + "type": "created", + "comment": { + "did": "did:plc:a", + "rkey": "r1", + "uri": "at://did:plc:a/xyz.effem.feed.comment/r1", + "subject": {"uri": "at://catalog/ep/1", "cid": "cid-ep"}, + "text": "hi", + "createdAt": "2026-05-15T10:00:00.000Z", + "like_count": 0 + } } """.data(using: .utf8)! - let bookmark = try JSONDecoder().decode(Bookmark.self, from: json) - #expect(bookmark.timestamp == 300) - #expect(bookmark.uri == "at://did:plc:xyz/xyz.effem.feed.bookmark/tid123") + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let event = try decoder.decode(CommentEvent.self, from: json) + guard case .created(let c) = event else { + #expect(Bool(false), "expected .created event") + return + } + #expect(c.did == "did:plc:a") + #expect(c.text == "hi") + } + + @Test func decodesDeleted() throws { + let json = """ + {"type": "deleted", "uri": "at://did:plc:a/xyz.effem.feed.comment/r1"} + """.data(using: .utf8)! + let event = try JSONDecoder().decode(CommentEvent.self, from: json) + guard case .deleted(let uri) = event else { + #expect(Bool(false), "expected .deleted event") + return + } + #expect(uri == "at://did:plc:a/xyz.effem.feed.comment/r1") + } + + @Test func decodesLiked() throws { + let json = """ + {"type": "liked", "commentUri": "at://x/y/z", "likeCount": 5, "actorDid": "did:plc:liker"} + """.data(using: .utf8)! + let event = try JSONDecoder().decode(CommentEvent.self, from: json) + guard case .liked(let uri, let count, let actor) = event else { + #expect(Bool(false), "expected .liked event") + return + } + #expect(uri == "at://x/y/z") + #expect(count == 5) + #expect(actor == "did:plc:liker") + } + + @Test func decodesUnliked() throws { + let json = """ + {"type": "unliked", "commentUri": "at://x/y/z", "likeCount": 4} + """.data(using: .utf8)! + let event = try JSONDecoder().decode(CommentEvent.self, from: json) + guard case .unliked(let uri, let count, let actor) = event else { + #expect(Bool(false), "expected .unliked event") + return + } + #expect(uri == "at://x/y/z") + #expect(count == 4) + #expect(actor == nil) + } + + @Test func rejectsUnknownType() { + let json = """ + {"type": "frobnicated"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(CommentEvent.self, from: json) + } } } -@Suite("PodcastList") -struct PodcastListTests { +@Suite("EffemNotification") +struct EffemNotificationTests { @Test func decodesFromJSON() throws { let json = """ { - "did": "did:plc:xyz", - "rkey": "list1", - "name": "My Favorite Pods", - "description": "The best ones", - "podcasts": [{"feedId": 1}, {"feedId": 2, "feedUrl": "https://example.com/rss"}], - "createdAt": "2026-01-01T00:00:00Z" + "id": 42, + "reason": "reply", + "actorDid": "did:plc:actor", + "subjectUri": "at://x/parent", + "sourceUri": "at://y/reply", + "createdAt": "2026-05-15T10:00:00.000Z", + "isRead": false, + "actor": { + "did": "did:plc:actor", + "handle": "bob.bsky.social", + "displayName": "Bob" + } } """.data(using: .utf8)! - - let list = try JSONDecoder().decode(PodcastList.self, from: json) - #expect(list.name == "My Favorite Pods") - #expect(list.podcasts.count == 2) - #expect(list.podcasts[1].feedUrl == "https://example.com/rss") + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let note = try decoder.decode(EffemNotification.self, from: json) + #expect(note.id == 42) + #expect(note.reason == .reply) + #expect(note.isRead == false) + #expect(note.actor?.handle == "bob.bsky.social") } } -@Suite("CommentReplyRef") -struct CommentReplyRefTests { - @Test func initializesCorrectly() { - let ref = CommentReplyRef( - rootURI: "at://did:plc:a/xyz.effem.feed.comment/root", - rootCID: "bafyreia", - parentURI: "at://did:plc:b/xyz.effem.feed.comment/parent", - parentCID: "bafyreib" +@Suite("Moderation") +struct ModerationTests { + @Test func threadgateAllowRuleDecodesMention() throws { + let json = #"{"$type": "xyz.effem.feed.threadgate#mentionRule"}"#.data(using: .utf8)! + let rule = try JSONDecoder().decode(ThreadgateAllowRule.self, from: json) + #expect(rule == .mention) + } + + @Test func threadgateAllowRuleDecodesUnknownAsCase() throws { + let json = #"{"$type": "xyz.effem.feed.threadgate#fooRule"}"#.data(using: .utf8)! + let rule = try JSONDecoder().decode(ThreadgateAllowRule.self, from: json) + if case .unknown(let type) = rule { + #expect(type == "xyz.effem.feed.threadgate#fooRule") + } else { + #expect(Bool(false), "expected .unknown case") + } + } + + @Test func threadgateAllowRuleEncodesType() throws { + let rule = ThreadgateAllowRule.mention + let data = try JSONEncoder().encode(rule) + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(decoded?["$type"] as? String == "xyz.effem.feed.threadgate#mentionRule") + } + + @Test func threadSettingsOpenWhenAllowSetFalse() throws { + let json = #"{"allow_set": false}"#.data(using: .utf8)! + let settings = try JSONDecoder().decode(ThreadSettings.self, from: json) + #expect(settings.isOpen) + #expect(!settings.isLocked) + #expect(!settings.isMentionOnly) + } + + @Test func threadSettingsLockedWhenAllowEmpty() throws { + let json = #"{"allow_set": true, "allow": []}"#.data(using: .utf8)! + let settings = try JSONDecoder().decode(ThreadSettings.self, from: json) + #expect(!settings.isOpen) + #expect(settings.isLocked) + } + + @Test func threadSettingsMentionOnlyWhenSingleMentionRule() throws { + let json = """ + {"allow_set": true, "allow": [{"$type": "xyz.effem.feed.threadgate#mentionRule"}]} + """.data(using: .utf8)! + let settings = try JSONDecoder().decode(ThreadSettings.self, from: json) + #expect(!settings.isOpen) + #expect(!settings.isLocked) + #expect(settings.isMentionOnly) + } + + @Test func moderationLabelDecodesAndDerivesFlags() throws { + let json = """ + {"id": 7, "src": "did:web:labeler.effem.app", "subject_uri": "at://x", "val": "!hide", "neg": false, "created_at": "2026-05-15T10:00:00Z"} + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let label = try decoder.decode(ModerationLabel.self, from: json) + #expect(label.id == 7) + #expect(label.val == "!hide") + #expect(label.isHide) + #expect(!label.isWarn) + } + + @Test func commentDecodesSelfLabelsAndModerationLabels() throws { + let json = """ + { + "did": "did:plc:a", "rkey": "r1", + "uri": "at://did:plc:a/xyz.effem.feed.comment/r1", + "subject": {"uri": "at://catalog/ep/1", "cid": "cid-ep"}, + "text": "spicy take", "createdAt": "2026-05-15T10:00:00.000Z", + "like_count": 0, + "self_labels": ["sexual", "graphic-media"], + "labels": [ + {"id": 1, "src": "did:web:labeler.effem.app", "subject_uri": "at://did:plc:a/xyz.effem.feed.comment/r1", "val": "spam", "neg": false, "created_at": "2026-05-15T10:00:00Z"} + ] + } + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let comment = try decoder.decode(EffemKit.Comment.self, from: json) + #expect(comment.selfLabels == ["sexual", "graphic-media"]) + #expect(comment.labels?.count == 1) + #expect(comment.labels?.first?.val == "spam") + #expect(comment.needsContentWarning) // self-labels present + #expect(!comment.isHidden) // no !hide label + } + + @Test func commentIsHiddenWhenHideLabelPresent() { + let label = ModerationLabel(id: 1, src: "did:web:labeler", subjectUri: "at://x", val: "!hide", neg: false, createdAt: "2026-05-15T10:00:00Z") + let comment = EffemKit.Comment( + did: "did:plc:x", rkey: "r", + uri: "at://did:plc:x/xyz.effem.feed.comment/r", + subject: StrongRef(uri: "at://catalog/ep", cid: "cid"), + text: "hi", labels: [label], + createdAt: "2026-05-15T10:00:00.000Z" ) - #expect(ref.rootURI.contains("root")) - #expect(ref.parentCID == "bafyreib") + #expect(comment.isHidden) + } + + @Test func selfLabelValueRawValues() { + #expect(SelfLabelValue.sexual.rawValue == "sexual") + #expect(SelfLabelValue.graphicMedia.rawValue == "graphic-media") + #expect(SelfLabelValue.allCases.count == 4) + } +} + +@Suite("ATDate") +struct ATDateTests { + @Test func nowEmitsFractionalSeconds() { + let stamp = ATDate.now() + // Strict AT Protocol datetime requires millisecond subseconds. + // Format: 2026-05-15T10:30:00.123Z + #expect(stamp.contains(".")) + #expect(stamp.hasSuffix("Z")) + } + + @Test func parsesWithFractionalSeconds() { + let parsed = ATDate.parse("2026-05-15T10:30:00.123Z") + #expect(parsed != nil) + } + + @Test func parsesWithoutFractionalSeconds() { + // Older records and Bluesky's default formatter emit whole seconds. + // We must still accept these on the read path. + let parsed = ATDate.parse("2026-05-15T10:30:00Z") + #expect(parsed != nil) + } + + @Test func returnsNilForInvalidInput() { + #expect(ATDate.parse("not-a-date") == nil) + #expect(ATDate.parse("") == nil) + } + + @Test func roundTripPreservesInstant() { + let original = Date(timeIntervalSince1970: 1_780_000_000) + let formatted = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: original) + }() + let parsed = ATDate.parse(formatted) + #expect(parsed != nil) + if let parsed { + // Allow 1ms tolerance for fractional-second rounding. + let delta = abs(parsed.timeIntervalSince(original)) + #expect(delta < 0.01) + } } }