iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: Grain native iOS app

SwiftUI app with AT Protocol OAuth + DPoP auth, feed with photo
carousels, search, notifications, profiles, gallery creation,
and liquid glass tab bar. Uploaded build 2 to TestFlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Chad Miller 416e8e3c

+3752
+31
.gitignore
··· 1 + # Xcode 2 + Grain.xcodeproj/ 3 + *.xcodeproj/xcuserdata/ 4 + *.xcworkspace/xcuserdata/ 5 + DerivedData/ 6 + build/ 7 + *.pbxuser 8 + *.mode1v3 9 + *.mode2v3 10 + *.perspectivev3 11 + *.xccheckout 12 + *.moved-aside 13 + *.hmap 14 + *.ipa 15 + *.dSYM.zip 16 + *.dSYM 17 + 18 + # Swift Package Manager 19 + .build/ 20 + .swiftpm/ 21 + Packages/ 22 + Package.pins 23 + Package.resolved 24 + 25 + # CocoaPods (not used but just in case) 26 + Pods/ 27 + 28 + # macOS 29 + .DS_Store 30 + *.swp 31 + *~
+323
Grain/API/AuthManager.swift
··· 1 + import AuthenticationServices 2 + import CryptoKit 3 + import Foundation 4 + import os 5 + 6 + private let logger = Logger(subsystem: "social.grain.grain", category: "Auth") 7 + 8 + /// Manages OAuth + DPoP authentication flow against the hatk server. 9 + @Observable 10 + @MainActor 11 + final class AuthManager { 12 + var isAuthenticated = false 13 + var userDID: String? 14 + var userHandle: String? 15 + var userAvatar: String? 16 + var avatarImage: UIImage? 17 + 18 + private(set) var dpop: DPoP? 19 + private var codeVerifier: String? 20 + private var client: XRPCClient? 21 + 22 + #if DEBUG 23 + static let serverURL = URL(string: "http://127.0.0.1:3000")! 24 + #else 25 + static let serverURL = URL(string: "https://grain.social")! 26 + #endif 27 + static let clientID = "grain-native://app" 28 + static let redirectURI = "grain://oauth/callback" 29 + 30 + init() { 31 + // Restore session from Keychain 32 + if let token = TokenStorage.accessToken, 33 + let did = TokenStorage.userDID, 34 + !TokenStorage.isExpired { 35 + self.isAuthenticated = true 36 + self.userDID = did 37 + self.userHandle = TokenStorage.userHandle 38 + self.userAvatar = TokenStorage.userAvatar 39 + self.dpop = try? DPoP.loadOrCreate() 40 + _ = token // Token is available via TokenStorage 41 + } 42 + } 43 + 44 + /// Start the OAuth login flow. 45 + func login(handle: String) async throws { 46 + let dpop = try DPoP.loadOrCreate() 47 + self.dpop = dpop 48 + 49 + let client = XRPCClient(baseURL: Self.serverURL) 50 + self.client = client 51 + 52 + // Generate PKCE code verifier + challenge 53 + let verifier = generateCodeVerifier() 54 + self.codeVerifier = verifier 55 + let challenge = generateCodeChallenge(verifier: verifier) 56 + 57 + // Step 1: Pushed Authorization Request 58 + let parBody: [String: String] = [ 59 + "client_id": Self.clientID, 60 + "redirect_uri": Self.redirectURI, 61 + "response_type": "code", 62 + "code_challenge": challenge, 63 + "code_challenge_method": "S256", 64 + "scope": "atproto blob:image/* repo:social.grain.gallery repo:social.grain.gallery.item repo:social.grain.photo repo:social.grain.photo.exif repo:social.grain.actor.profile repo:social.grain.graph.follow repo:social.grain.favorite repo:social.grain.comment repo:social.grain.story repo:app.bsky.feed.post?action=create", 65 + "login_hint": handle 66 + ] 67 + 68 + let parURL = Self.serverURL.appendingPathComponent("oauth/par") 69 + var parRequest = URLRequest(url: parURL) 70 + parRequest.httpMethod = "POST" 71 + parRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 72 + parRequest.httpBody = parBody.urlEncoded.data(using: .utf8) 73 + 74 + let parProof = try await dpop.createProof(httpMethod: "POST", url: parURL) 75 + parRequest.setValue(parProof, forHTTPHeaderField: "DPoP") 76 + 77 + var (parData, parHTTPResponse) = try await URLSession.shared.data(for: parRequest) 78 + 79 + // Handle DPoP nonce requirement on PAR 80 + if let httpResp = parHTTPResponse as? HTTPURLResponse, 81 + httpResp.statusCode == 400, 82 + let nonce = httpResp.value(forHTTPHeaderField: "DPoP-Nonce") { 83 + let retryProof = try await dpop.createProof(httpMethod: "POST", url: parURL, nonce: nonce) 84 + parRequest.setValue(retryProof, forHTTPHeaderField: "DPoP") 85 + (parData, parHTTPResponse) = try await URLSession.shared.data(for: parRequest) 86 + } 87 + 88 + // Log response for debugging 89 + if let httpResp = parHTTPResponse as? HTTPURLResponse, httpResp.statusCode != 200 && httpResp.statusCode != 201 { 90 + let body = String(data: parData, encoding: .utf8) ?? "no body" 91 + print("[Grain Auth] PAR failed (\(httpResp.statusCode)): \(body)") 92 + throw XRPCError.httpError(statusCode: httpResp.statusCode, body: parData) 93 + } 94 + 95 + let parResponse = try JSONDecoder().decode(PARResponse.self, from: parData) 96 + 97 + // Step 2: Open browser for authorization 98 + var authComponents = URLComponents(url: Self.serverURL.appendingPathComponent("oauth/authorize"), resolvingAgainstBaseURL: false)! 99 + authComponents.queryItems = [ 100 + URLQueryItem(name: "request_uri", value: parResponse.requestUri), 101 + URLQueryItem(name: "client_id", value: Self.clientID) 102 + ] 103 + 104 + let authURL = authComponents.url! 105 + let callbackURL: URL = try await withCheckedThrowingContinuation { continuation in 106 + let session = ASWebAuthenticationSession( 107 + url: authURL, 108 + callback: .customScheme("grain") 109 + ) { url, error in 110 + if let error { continuation.resume(throwing: error); return } 111 + guard let url else { continuation.resume(throwing: XRPCError.invalidURL); return } 112 + continuation.resume(returning: url) 113 + } 114 + session.prefersEphemeralWebBrowserSession = false 115 + session.presentationContextProvider = WebAuthContextProvider.shared 116 + session.start() 117 + } 118 + 119 + // Step 3: Exchange code for tokens 120 + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), 121 + let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { 122 + throw XRPCError.invalidURL 123 + } 124 + 125 + try await exchangeCode(code: code, dpop: dpop) 126 + await fetchAndStoreAvatar() 127 + } 128 + 129 + /// Refresh the access token using the refresh token. 130 + func refresh() async throws { 131 + guard let dpop, let refreshToken = TokenStorage.refreshToken else { 132 + throw XRPCError.unauthorized 133 + } 134 + 135 + let tokenURL = Self.serverURL.appendingPathComponent("oauth/token") 136 + var request = URLRequest(url: tokenURL) 137 + request.httpMethod = "POST" 138 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 139 + 140 + let body: [String: String] = [ 141 + "grant_type": "refresh_token", 142 + "refresh_token": refreshToken, 143 + "client_id": Self.clientID 144 + ] 145 + request.httpBody = body.urlEncoded.data(using: .utf8) 146 + 147 + let proof = try await dpop.createProof(httpMethod: "POST", url: tokenURL) 148 + request.setValue(proof, forHTTPHeaderField: "DPoP") 149 + 150 + let (data, _) = try await URLSession.shared.data(for: request) 151 + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) 152 + storeTokens(tokenResponse) 153 + } 154 + 155 + /// Log out and clear all stored credentials. 156 + func logout() { 157 + TokenStorage.clear() 158 + try? DPoP.clearKey() 159 + isAuthenticated = false 160 + userDID = nil 161 + userHandle = nil 162 + dpop = nil 163 + } 164 + 165 + /// Build an AuthContext for making authenticated requests. 166 + func authContext() -> AuthContext? { 167 + guard let dpop, let token = TokenStorage.accessToken else { return nil } 168 + return AuthContext(accessToken: token, dpop: dpop) 169 + } 170 + 171 + // MARK: - Private 172 + 173 + private func exchangeCode(code: String, dpop: DPoP) async throws { 174 + let tokenURL = Self.serverURL.appendingPathComponent("oauth/token") 175 + var request = URLRequest(url: tokenURL) 176 + request.httpMethod = "POST" 177 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 178 + 179 + let body: [String: String] = [ 180 + "grant_type": "authorization_code", 181 + "code": code, 182 + "redirect_uri": Self.redirectURI, 183 + "client_id": Self.clientID, 184 + "code_verifier": codeVerifier ?? "" 185 + ] 186 + request.httpBody = body.urlEncoded.data(using: .utf8) 187 + 188 + let proof = try await dpop.createProof(httpMethod: "POST", url: tokenURL) 189 + request.setValue(proof, forHTTPHeaderField: "DPoP") 190 + 191 + let (data, response) = try await URLSession.shared.data(for: request) 192 + 193 + // Handle DPoP nonce retry 194 + if let httpResponse = response as? HTTPURLResponse, 195 + httpResponse.statusCode == 400, 196 + let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 197 + let retryProof = try await dpop.createProof(httpMethod: "POST", url: tokenURL, nonce: nonce) 198 + var retryRequest = request 199 + retryRequest.setValue(retryProof, forHTTPHeaderField: "DPoP") 200 + let (retryData, _) = try await URLSession.shared.data(for: retryRequest) 201 + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: retryData) 202 + storeTokens(tokenResponse) 203 + return 204 + } 205 + 206 + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) 207 + storeTokens(tokenResponse) 208 + } 209 + 210 + private func storeTokens(_ response: TokenResponse) { 211 + TokenStorage.accessToken = response.accessToken 212 + TokenStorage.refreshToken = response.refreshToken 213 + TokenStorage.userDID = response.sub 214 + TokenStorage.userHandle = response.handle 215 + TokenStorage.tokenExpiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn)) 216 + 217 + isAuthenticated = true 218 + userDID = response.sub 219 + userHandle = response.handle 220 + } 221 + 222 + func fetchAvatarIfNeeded() async { 223 + if userAvatar != nil && avatarImage == nil { 224 + await downloadAvatarImage() 225 + } 226 + if userAvatar == nil && userDID != nil { 227 + await fetchAndStoreAvatar() 228 + } 229 + } 230 + 231 + private func fetchAndStoreAvatar() async { 232 + guard let did = userDID else { return } 233 + let client = XRPCClient(baseURL: Self.serverURL) 234 + do { 235 + let profile = try await client.getActorProfile(actor: did) 236 + userAvatar = profile.avatar 237 + TokenStorage.userAvatar = profile.avatar 238 + } catch { 239 + logger.error("Avatar fetch failed: \(error)") 240 + } 241 + await downloadAvatarImage() 242 + } 243 + 244 + private func downloadAvatarImage() async { 245 + guard let urlString = userAvatar, let url = URL(string: urlString) else { return } 246 + do { 247 + let (data, _) = try await URLSession.shared.data(from: url) 248 + if let image = UIImage(data: data) { 249 + avatarImage = image 250 + } 251 + } catch { 252 + logger.error("Avatar download failed: \(error)") 253 + } 254 + } 255 + 256 + private func generateCodeVerifier() -> String { 257 + var bytes = [UInt8](repeating: 0, count: 32) 258 + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) 259 + return Data(bytes).base64URLEncoded() 260 + } 261 + 262 + private func generateCodeChallenge(verifier: String) -> String { 263 + let hash = SHA256.hash(data: Data(verifier.utf8)) 264 + return Data(hash).base64URLEncoded() 265 + } 266 + } 267 + 268 + // MARK: - Response Types 269 + 270 + private struct PARResponse: Codable { 271 + let requestUri: String 272 + let expiresIn: Int 273 + 274 + enum CodingKeys: String, CodingKey { 275 + case requestUri = "request_uri" 276 + case expiresIn = "expires_in" 277 + } 278 + } 279 + 280 + private struct TokenResponse: Codable { 281 + let accessToken: String 282 + let tokenType: String 283 + let expiresIn: Int 284 + let refreshToken: String? 285 + let sub: String 286 + let handle: String? 287 + 288 + enum CodingKeys: String, CodingKey { 289 + case accessToken = "access_token" 290 + case tokenType = "token_type" 291 + case expiresIn = "expires_in" 292 + case refreshToken = "refresh_token" 293 + case sub 294 + case handle 295 + } 296 + } 297 + 298 + // MARK: - ASWebAuthenticationSession Context 299 + 300 + import UIKit 301 + 302 + final class WebAuthContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { 303 + static let shared = WebAuthContextProvider() 304 + 305 + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 306 + UIApplication.shared.connectedScenes 307 + .compactMap { $0 as? UIWindowScene } 308 + .flatMap(\.windows) 309 + .first(where: \.isKeyWindow) ?? ASPresentationAnchor() 310 + } 311 + } 312 + 313 + // MARK: - Helpers 314 + 315 + extension Dictionary where Key == String, Value == String { 316 + var urlEncoded: String { 317 + map { key, value in 318 + let escapedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key 319 + let escapedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value 320 + return "\(escapedKey)=\(escapedValue)" 321 + }.joined(separator: "&") 322 + } 323 + }
+141
Grain/API/DPoP.swift
··· 1 + import Foundation 2 + import CryptoKit 3 + import Security 4 + 5 + /// DPoP (Demonstration of Proof-of-Possession) proof generator using ES256. 6 + final class DPoP: Sendable { 7 + nonisolated(unsafe) private let privateKey: P256.Signing.PrivateKey 8 + let publicJWK: [String: String] 9 + let thumbprint: String 10 + 11 + init(privateKey: P256.Signing.PrivateKey) { 12 + self.privateKey = privateKey 13 + let publicKey = privateKey.publicKey 14 + let rawRepresentation = publicKey.rawRepresentation 15 + let x = rawRepresentation.prefix(32) 16 + let y = rawRepresentation.suffix(32) 17 + 18 + self.publicJWK = [ 19 + "kty": "EC", 20 + "crv": "P-256", 21 + "x": x.base64URLEncoded(), 22 + "y": y.base64URLEncoded() 23 + ] 24 + 25 + // JWK thumbprint (RFC 7638) — lexicographic JSON of required members 26 + let thumbprintInput = #"{"crv":"P-256","kty":"EC","x":"\#(x.base64URLEncoded())","y":"\#(y.base64URLEncoded())"}"# 27 + let hash = SHA256.hash(data: Data(thumbprintInput.utf8)) 28 + self.thumbprint = Data(hash).base64URLEncoded() 29 + } 30 + 31 + /// Create a DPoP proof JWT. 32 + func createProof( 33 + httpMethod: String, 34 + url: URL, 35 + accessToken: String? = nil, 36 + nonce: String? = nil 37 + ) async throws -> String { 38 + // Normalize URL: scheme + host + path (no query) 39 + var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! 40 + components.query = nil 41 + components.fragment = nil 42 + let htu = components.url!.absoluteString 43 + 44 + // Header 45 + let header: [String: Any] = [ 46 + "typ": "dpop+jwt", 47 + "alg": "ES256", 48 + "jwk": publicJWK 49 + ] 50 + 51 + // Payload 52 + var payload: [String: Any] = [ 53 + "jti": UUID().uuidString, 54 + "htm": httpMethod.uppercased(), 55 + "htu": htu, 56 + "iat": Int(Date().timeIntervalSince1970) 57 + ] 58 + 59 + if let accessToken { 60 + let tokenHash = SHA256.hash(data: Data(accessToken.utf8)) 61 + payload["ath"] = Data(tokenHash).base64URLEncoded() 62 + } 63 + 64 + if let nonce { 65 + payload["nonce"] = nonce 66 + } 67 + 68 + let headerData = try JSONSerialization.data(withJSONObject: header) 69 + let payloadData = try JSONSerialization.data(withJSONObject: payload) 70 + 71 + let signingInput = headerData.base64URLEncoded() + "." + payloadData.base64URLEncoded() 72 + let signature = try privateKey.signature(for: Data(signingInput.utf8)) 73 + 74 + return signingInput + "." + signature.rawRepresentation.base64URLEncoded() 75 + } 76 + } 77 + 78 + // MARK: - Key Management 79 + 80 + extension DPoP { 81 + private static let keychainService = "social.grain.dpop" 82 + private static let keychainAccount = "dpop-private-key" 83 + 84 + /// Load existing key from Keychain or generate a new one. 85 + static func loadOrCreate() throws -> DPoP { 86 + if let existingKey = try loadFromKeychain() { 87 + return DPoP(privateKey: existingKey) 88 + } 89 + let newKey = P256.Signing.PrivateKey() 90 + try saveToKeychain(newKey) 91 + return DPoP(privateKey: newKey) 92 + } 93 + 94 + /// Remove the stored key (for logout). 95 + static func clearKey() throws { 96 + let query: [String: Any] = [ 97 + kSecClass as String: kSecClassGenericPassword, 98 + kSecAttrService as String: keychainService, 99 + kSecAttrAccount as String: keychainAccount 100 + ] 101 + SecItemDelete(query as CFDictionary) 102 + } 103 + 104 + private static func loadFromKeychain() throws -> P256.Signing.PrivateKey? { 105 + let query: [String: Any] = [ 106 + kSecClass as String: kSecClassGenericPassword, 107 + kSecAttrService as String: keychainService, 108 + kSecAttrAccount as String: keychainAccount, 109 + kSecReturnData as String: true 110 + ] 111 + var result: AnyObject? 112 + let status = SecItemCopyMatching(query as CFDictionary, &result) 113 + guard status == errSecSuccess, let data = result as? Data else { return nil } 114 + return try P256.Signing.PrivateKey(rawRepresentation: data) 115 + } 116 + 117 + private static func saveToKeychain(_ key: P256.Signing.PrivateKey) throws { 118 + let query: [String: Any] = [ 119 + kSecClass as String: kSecClassGenericPassword, 120 + kSecAttrService as String: keychainService, 121 + kSecAttrAccount as String: keychainAccount, 122 + kSecValueData as String: key.rawRepresentation, 123 + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock 124 + ] 125 + let status = SecItemAdd(query as CFDictionary, nil) 126 + guard status == errSecSuccess else { 127 + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) 128 + } 129 + } 130 + } 131 + 132 + // MARK: - Base64URL 133 + 134 + extension Data { 135 + func base64URLEncoded() -> String { 136 + base64EncodedString() 137 + .replacingOccurrences(of: "+", with: "-") 138 + .replacingOccurrences(of: "/", with: "_") 139 + .replacingOccurrences(of: "=", with: "") 140 + } 141 + }
+32
Grain/API/Endpoints/DiscoveryEndpoints.swift
··· 1 + import Foundation 2 + 3 + struct GetLocationsResponse: Codable, Sendable { 4 + var locations: [LocationItem]? 5 + } 6 + 7 + struct LocationItem: Codable, Sendable, Identifiable { 8 + let name: String 9 + let h3Index: String 10 + let galleryCount: Int 11 + var id: String { h3Index } 12 + } 13 + 14 + struct GetCamerasResponse: Codable, Sendable { 15 + var cameras: [CameraItem]? 16 + } 17 + 18 + struct CameraItem: Codable, Sendable, Identifiable { 19 + let camera: String 20 + let photoCount: Int 21 + var id: String { camera } 22 + } 23 + 24 + extension XRPCClient { 25 + func getLocations(auth: AuthContext? = nil) async throws -> GetLocationsResponse { 26 + try await query("social.grain.unspecced.getLocations", auth: auth, as: GetLocationsResponse.self) 27 + } 28 + 29 + func getCameras(auth: AuthContext? = nil) async throws -> GetCamerasResponse { 30 + try await query("social.grain.unspecced.getCameras", auth: auth, as: GetCamerasResponse.self) 31 + } 32 + }
+114
Grain/API/Endpoints/FeedEndpoints.swift
··· 1 + import Foundation 2 + 3 + /// Response types for feed-related XRPC queries. 4 + 5 + struct GetFeedResponse: Codable, Sendable { 6 + var items: [GrainGallery]? 7 + var cursor: String? 8 + } 9 + 10 + struct GetGalleryResponse: Codable, Sendable { 11 + let gallery: GrainGallery 12 + } 13 + 14 + struct GetGalleryThreadResponse: Codable, Sendable { 15 + let comments: [GrainComment] 16 + var cursor: String? 17 + var totalCount: Int? 18 + } 19 + 20 + struct GetPreferencesResponse: Codable, Sendable { 21 + let preferences: UserPreferences 22 + } 23 + 24 + struct UserPreferences: Codable, Sendable { 25 + var pinnedFeeds: [PinnedFeed]? 26 + var includeExif: Bool? 27 + } 28 + 29 + struct PinnedFeed: Codable, Sendable, Identifiable, Hashable { 30 + let id: String 31 + let label: String 32 + let type: String 33 + let path: String 34 + 35 + static let defaults: [PinnedFeed] = [ 36 + PinnedFeed(id: "recent", label: "Recent", type: "feed", path: "/"), 37 + PinnedFeed(id: "following", label: "Following", type: "feed", path: "/feeds/following"), 38 + ] 39 + 40 + /// The feed name parameter for the API (e.g. "recent", "following", "camera", "location", "hashtag") 41 + var feedName: String { 42 + switch type { 43 + case "camera": "camera" 44 + case "location": "location" 45 + case "hashtag": "hashtag" 46 + default: id 47 + } 48 + } 49 + 50 + /// Extract the value part from the id (e.g. "Sony A7III" from "camera:Sony A7III") 51 + var feedValue: String? { 52 + guard id.contains(":") else { return nil } 53 + return String(id.split(separator: ":", maxSplits: 1).last ?? "") 54 + } 55 + } 56 + 57 + struct SearchGalleriesResponse: Codable, Sendable { 58 + var items: [GrainGallery]? 59 + var cursor: String? 60 + } 61 + 62 + // MARK: - Convenience Extensions 63 + 64 + extension XRPCClient { 65 + func getFeed( 66 + feed: String, 67 + limit: Int = 30, 68 + cursor: String? = nil, 69 + actor: String? = nil, 70 + camera: String? = nil, 71 + location: String? = nil, 72 + tag: String? = nil, 73 + auth: AuthContext? = nil 74 + ) async throws -> GetFeedResponse { 75 + var params = ["feed": feed, "limit": String(limit)] 76 + if let cursor { params["cursor"] = cursor } 77 + if let actor { params["actor"] = actor } 78 + if let camera { params["camera"] = camera } 79 + if let location { params["location"] = location } 80 + if let tag { params["tag"] = tag } 81 + return try await query("dev.hatk.getFeed", params: params, auth: auth, as: GetFeedResponse.self) 82 + } 83 + 84 + func getPreferences(auth: AuthContext? = nil) async throws -> GetPreferencesResponse { 85 + try await query("dev.hatk.getPreferences", auth: auth, as: GetPreferencesResponse.self) 86 + } 87 + 88 + func getGallery(uri: String, auth: AuthContext? = nil) async throws -> GetGalleryResponse { 89 + try await query("social.grain.unspecced.getGallery", params: ["gallery": uri], auth: auth, as: GetGalleryResponse.self) 90 + } 91 + 92 + func getGalleryThread( 93 + gallery: String, 94 + limit: Int = 20, 95 + cursor: String? = nil, 96 + auth: AuthContext? = nil 97 + ) async throws -> GetGalleryThreadResponse { 98 + var params = ["gallery": gallery, "limit": String(limit)] 99 + if let cursor { params["cursor"] = cursor } 100 + return try await query("social.grain.unspecced.getGalleryThread", params: params, auth: auth, as: GetGalleryThreadResponse.self) 101 + } 102 + 103 + func searchGalleries( 104 + query q: String, 105 + limit: Int = 30, 106 + cursor: String? = nil, 107 + fuzzy: Bool = true, 108 + auth: AuthContext? = nil 109 + ) async throws -> SearchGalleriesResponse { 110 + var params = ["q": q, "limit": String(limit), "fuzzy": String(fuzzy)] 111 + if let cursor { params["cursor"] = cursor } 112 + return try await self.query("social.grain.unspecced.searchGalleries", params: params, auth: auth, as: SearchGalleriesResponse.self) 113 + } 114 + }
+21
Grain/API/Endpoints/NotificationEndpoints.swift
··· 1 + import Foundation 2 + 3 + struct GetNotificationsResponse: Codable, Sendable { 4 + let notifications: [GrainNotification] 5 + var cursor: String? 6 + var unseenCount: Int? 7 + } 8 + 9 + extension XRPCClient { 10 + func getNotifications( 11 + limit: Int = 20, 12 + cursor: String? = nil, 13 + countOnly: Bool = false, 14 + auth: AuthContext? = nil 15 + ) async throws -> GetNotificationsResponse { 16 + var params = ["limit": String(limit)] 17 + if let cursor { params["cursor"] = cursor } 18 + if countOnly { params["countOnly"] = "true" } 19 + return try await query("social.grain.unspecced.getNotifications", params: params, auth: auth, as: GetNotificationsResponse.self) 20 + } 21 + }
+97
Grain/API/Endpoints/ProfileEndpoints.swift
··· 1 + import Foundation 2 + 3 + /// Response types for profile-related XRPC queries. 4 + 5 + struct GetFollowersResponse: Codable, Sendable { 6 + var items: [FollowerItem]? 7 + var cursor: String? 8 + } 9 + 10 + struct GetFollowingResponse: Codable, Sendable { 11 + var items: [FollowingItem]? 12 + var cursor: String? 13 + } 14 + 15 + struct GetKnownFollowersResponse: Codable, Sendable { 16 + var items: [FollowerItem]? 17 + } 18 + 19 + struct GetSuggestedFollowsResponse: Codable, Sendable { 20 + var items: [SuggestedItem]? 21 + } 22 + 23 + struct SearchProfilesResponse: Codable, Sendable { 24 + var items: [ProfileSearchResult]? 25 + var cursor: String? 26 + } 27 + 28 + struct FollowerItem: Codable, Sendable, Identifiable { 29 + let did: String 30 + var handle: String? 31 + var displayName: String? 32 + var description: String? 33 + var avatar: String? 34 + var id: String { did } 35 + } 36 + 37 + struct FollowingItem: Codable, Sendable, Identifiable { 38 + let did: String 39 + var handle: String? 40 + var displayName: String? 41 + var description: String? 42 + var avatar: String? 43 + var id: String { did } 44 + } 45 + 46 + struct SuggestedItem: Codable, Sendable, Identifiable { 47 + let did: String 48 + var handle: String? 49 + var displayName: String? 50 + var description: String? 51 + var avatar: String? 52 + var followersCount: Int? 53 + var id: String { did } 54 + } 55 + 56 + struct ProfileSearchResult: Codable, Sendable, Identifiable { 57 + let did: String 58 + var handle: String? 59 + var displayName: String? 60 + var description: String? 61 + var avatar: String? 62 + var id: String { did } 63 + } 64 + 65 + // MARK: - Convenience Extensions 66 + 67 + extension XRPCClient { 68 + func getActorProfile(actor: String, auth: AuthContext? = nil) async throws -> GrainProfileDetailed { 69 + try await query("social.grain.unspecced.getActorProfile", params: ["actor": actor], auth: auth, as: GrainProfileDetailed.self) 70 + } 71 + 72 + func getFollowers(actor: String, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetFollowersResponse { 73 + var params = ["actor": actor, "limit": String(limit)] 74 + if let cursor { params["cursor"] = cursor } 75 + return try await query("social.grain.unspecced.getFollowers", params: params, auth: auth, as: GetFollowersResponse.self) 76 + } 77 + 78 + func getFollowing(actor: String, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetFollowingResponse { 79 + var params = ["actor": actor, "limit": String(limit)] 80 + if let cursor { params["cursor"] = cursor } 81 + return try await query("social.grain.unspecced.getFollowing", params: params, auth: auth, as: GetFollowingResponse.self) 82 + } 83 + 84 + func getKnownFollowers(actor: String, viewer: String, auth: AuthContext? = nil) async throws -> GetKnownFollowersResponse { 85 + try await query("social.grain.unspecced.getKnownFollowers", params: ["actor": actor, "viewer": viewer], auth: auth, as: GetKnownFollowersResponse.self) 86 + } 87 + 88 + func getSuggestedFollows(actor: String, limit: Int = 10, auth: AuthContext? = nil) async throws -> GetSuggestedFollowsResponse { 89 + try await query("social.grain.unspecced.getSuggestedFollows", params: ["actor": actor, "limit": String(limit)], auth: auth, as: GetSuggestedFollowsResponse.self) 90 + } 91 + 92 + func searchProfiles(query q: String, limit: Int = 30, cursor: String? = nil, auth: AuthContext? = nil) async throws -> SearchProfilesResponse { 93 + var params = ["q": q, "limit": String(limit)] 94 + if let cursor { params["cursor"] = cursor } 95 + return try await self.query("social.grain.unspecced.searchProfiles", params: params, auth: auth, as: SearchProfilesResponse.self) 96 + } 97 + }
+46
Grain/API/Endpoints/RecordEndpoints.swift
··· 1 + import Foundation 2 + 3 + struct CreateRecordInput: Codable, Sendable { 4 + let collection: String 5 + let repo: String 6 + let record: AnyCodable 7 + } 8 + 9 + struct CreateRecordResponse: Codable, Sendable { 10 + var uri: String? 11 + var cid: String? 12 + } 13 + 14 + struct PutRecordInput: Codable, Sendable { 15 + let collection: String 16 + let rkey: String 17 + let record: AnyCodable 18 + var repo: String? 19 + } 20 + 21 + struct DeleteRecordInput: Codable, Sendable { 22 + let collection: String 23 + let rkey: String 24 + } 25 + 26 + struct DeleteGalleryInput: Codable, Sendable { 27 + let rkey: String 28 + } 29 + 30 + extension XRPCClient { 31 + func createRecord(collection: String, repo: String, record: AnyCodable, auth: AuthContext? = nil) async throws -> CreateRecordResponse { 32 + try await procedure("dev.hatk.createRecord", input: CreateRecordInput(collection: collection, repo: repo, record: record), auth: auth, as: CreateRecordResponse.self) 33 + } 34 + 35 + func putRecord(collection: String, rkey: String, record: AnyCodable, repo: String? = nil, auth: AuthContext? = nil) async throws -> CreateRecordResponse { 36 + try await procedure("dev.hatk.putRecord", input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo), auth: auth, as: CreateRecordResponse.self) 37 + } 38 + 39 + func deleteRecord(collection: String, rkey: String, auth: AuthContext? = nil) async throws { 40 + try await procedure("dev.hatk.deleteRecord", input: DeleteRecordInput(collection: collection, rkey: rkey), auth: auth) 41 + } 42 + 43 + func deleteGallery(rkey: String, auth: AuthContext? = nil) async throws { 44 + try await procedure("social.grain.unspecced.deleteGallery", input: DeleteGalleryInput(rkey: rkey), auth: auth) 45 + } 46 + }
+38
Grain/API/Endpoints/StoryEndpoints.swift
··· 1 + import Foundation 2 + 3 + struct GetStoriesResponse: Codable, Sendable { 4 + let stories: [GrainStory] 5 + } 6 + 7 + struct GetStoryResponse: Codable, Sendable { 8 + var story: GrainStory? 9 + } 10 + 11 + struct GetStoryArchiveResponse: Codable, Sendable { 12 + let stories: [GrainStory] 13 + var cursor: String? 14 + } 15 + 16 + struct GetStoryAuthorsResponse: Codable, Sendable { 17 + let authors: [GrainStoryAuthor] 18 + } 19 + 20 + extension XRPCClient { 21 + func getStories(actor: String, auth: AuthContext? = nil) async throws -> GetStoriesResponse { 22 + try await query("social.grain.unspecced.getStories", params: ["actor": actor], auth: auth, as: GetStoriesResponse.self) 23 + } 24 + 25 + func getStory(uri: String, auth: AuthContext? = nil) async throws -> GetStoryResponse { 26 + try await query("social.grain.unspecced.getStory", params: ["story": uri], auth: auth, as: GetStoryResponse.self) 27 + } 28 + 29 + func getStoryArchive(actor: String, limit: Int = 50, cursor: String? = nil, auth: AuthContext? = nil) async throws -> GetStoryArchiveResponse { 30 + var params = ["actor": actor, "limit": String(limit)] 31 + if let cursor { params["cursor"] = cursor } 32 + return try await query("social.grain.unspecced.getStoryArchive", params: params, auth: auth, as: GetStoryArchiveResponse.self) 33 + } 34 + 35 + func getStoryAuthors(auth: AuthContext? = nil) async throws -> GetStoryAuthorsResponse { 36 + try await query("social.grain.unspecced.getStoryAuthors", auth: auth, as: GetStoryAuthorsResponse.self) 37 + } 38 + }
+73
Grain/API/TokenStorage.swift
··· 1 + import Foundation 2 + @preconcurrency import KeychainAccess 3 + 4 + /// Secure storage for OAuth tokens using Keychain. 5 + struct TokenStorage { 6 + private static let keychain = Keychain(service: "social.grain.oauth") 7 + 8 + static var accessToken: String? { 9 + get { try? keychain.get("access_token") } 10 + set { 11 + if let newValue { try? keychain.set(newValue, key: "access_token") } 12 + else { try? keychain.remove("access_token") } 13 + } 14 + } 15 + 16 + static var refreshToken: String? { 17 + get { try? keychain.get("refresh_token") } 18 + set { 19 + if let newValue { try? keychain.set(newValue, key: "refresh_token") } 20 + else { try? keychain.remove("refresh_token") } 21 + } 22 + } 23 + 24 + static var userDID: String? { 25 + get { try? keychain.get("user_did") } 26 + set { 27 + if let newValue { try? keychain.set(newValue, key: "user_did") } 28 + else { try? keychain.remove("user_did") } 29 + } 30 + } 31 + 32 + static var userHandle: String? { 33 + get { try? keychain.get("user_handle") } 34 + set { 35 + if let newValue { try? keychain.set(newValue, key: "user_handle") } 36 + else { try? keychain.remove("user_handle") } 37 + } 38 + } 39 + 40 + static var tokenExpiresAt: Date? { 41 + get { 42 + guard let str = try? keychain.get("token_expires_at"), 43 + let interval = Double(str) else { return nil } 44 + return Date(timeIntervalSince1970: interval) 45 + } 46 + set { 47 + if let newValue { try? keychain.set(String(newValue.timeIntervalSince1970), key: "token_expires_at") } 48 + else { try? keychain.remove("token_expires_at") } 49 + } 50 + } 51 + 52 + static var isExpired: Bool { 53 + guard let expiresAt = tokenExpiresAt else { return true } 54 + return Date() >= expiresAt 55 + } 56 + 57 + static var userAvatar: String? { 58 + get { try? keychain.get("user_avatar") } 59 + set { 60 + if let newValue { try? keychain.set(newValue, key: "user_avatar") } 61 + else { try? keychain.remove("user_avatar") } 62 + } 63 + } 64 + 65 + static func clear() { 66 + accessToken = nil 67 + refreshToken = nil 68 + userDID = nil 69 + userHandle = nil 70 + userAvatar = nil 71 + tokenExpiresAt = nil 72 + } 73 + }
+237
Grain/API/XRPCClient.swift
··· 1 + import Foundation 2 + import os 3 + 4 + private let logger = Logger(subsystem: "social.grain.grain", category: "XRPC") 5 + 6 + enum XRPCError: Error, LocalizedError { 7 + case invalidURL 8 + case httpError(statusCode: Int, body: Data?) 9 + case decodingError(Error) 10 + case unauthorized 11 + case dpopNonceRequired(nonce: String) 12 + 13 + var errorDescription: String? { 14 + switch self { 15 + case .invalidURL: "Invalid URL" 16 + case .httpError(let code, _): "HTTP error \(code)" 17 + case .decodingError(let error): "Decoding error: \(error.localizedDescription)" 18 + case .unauthorized: "Unauthorized" 19 + case .dpopNonceRequired: "DPoP nonce required" 20 + } 21 + } 22 + } 23 + 24 + /// XRPC client for communicating with the hatk server. 25 + @Observable 26 + final class XRPCClient: Sendable { 27 + let baseURL: URL 28 + private let session: URLSession 29 + private let decoder: JSONDecoder 30 + private let encoder: JSONEncoder 31 + 32 + init(baseURL: URL, session: URLSession = .shared) { 33 + self.baseURL = baseURL 34 + self.session = session 35 + self.decoder = JSONDecoder() 36 + self.encoder = JSONEncoder() 37 + } 38 + 39 + /// Execute an XRPC query (GET request). 40 + func query<T: Decodable>( 41 + _ nsid: String, 42 + params: [String: String] = [:], 43 + auth: AuthContext? = nil, 44 + as type: T.Type 45 + ) async throws -> T { 46 + var components = URLComponents(url: baseURL.appendingPathComponent("xrpc/\(nsid)"), resolvingAgainstBaseURL: false)! 47 + if !params.isEmpty { 48 + components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) } 49 + } 50 + guard let url = components.url else { throw XRPCError.invalidURL } 51 + 52 + var request = URLRequest(url: url) 53 + request.httpMethod = "GET" 54 + 55 + return try await executeWithRetry(request, auth: auth, as: type) 56 + } 57 + 58 + /// Execute an XRPC procedure (POST request). 59 + func procedure<I: Encodable, O: Decodable>( 60 + _ nsid: String, 61 + input: I, 62 + auth: AuthContext? = nil, 63 + as type: O.Type 64 + ) async throws -> O { 65 + let url = baseURL.appendingPathComponent("xrpc/\(nsid)") 66 + var request = URLRequest(url: url) 67 + request.httpMethod = "POST" 68 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 69 + request.httpBody = try encoder.encode(input) 70 + 71 + return try await executeWithRetry(request, auth: auth, as: type) 72 + } 73 + 74 + /// Execute an XRPC procedure with no response body. 75 + func procedure<I: Encodable>( 76 + _ nsid: String, 77 + input: I, 78 + auth: AuthContext? = nil 79 + ) async throws { 80 + let url = baseURL.appendingPathComponent("xrpc/\(nsid)") 81 + var request = URLRequest(url: url) 82 + request.httpMethod = "POST" 83 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") 84 + request.httpBody = try encoder.encode(input) 85 + 86 + try await executeVoidWithRetry(request, auth: auth) 87 + } 88 + 89 + /// Upload a blob (binary data). 90 + func uploadBlob( 91 + data: Data, 92 + mimeType: String, 93 + auth: AuthContext? = nil 94 + ) async throws -> UploadBlobResponse { 95 + let url = baseURL.appendingPathComponent("xrpc/dev.hatk.uploadBlob") 96 + var request = URLRequest(url: url) 97 + request.httpMethod = "POST" 98 + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") 99 + request.httpBody = data 100 + 101 + return try await executeWithRetry(request, auth: auth, as: UploadBlobResponse.self) 102 + } 103 + 104 + // MARK: - Private 105 + 106 + private func executeWithRetry<T: Decodable>( 107 + _ request: URLRequest, 108 + auth: AuthContext?, 109 + as type: T.Type, 110 + retryCount: Int = 0 111 + ) async throws -> T { 112 + var req = request 113 + try await applyAuth(&req, auth: auth) 114 + 115 + do { 116 + return try await execute(req, as: type) 117 + } catch XRPCError.dpopNonceRequired(let nonce) where retryCount < 2 { 118 + logger.info("DPoP nonce required, retrying with nonce") 119 + var updatedAuth = auth 120 + updatedAuth?.nonce = nonce 121 + var retryReq = request 122 + try await applyAuth(&retryReq, auth: updatedAuth) 123 + return try await execute(retryReq, as: type) 124 + } 125 + } 126 + 127 + private func executeVoidWithRetry( 128 + _ request: URLRequest, 129 + auth: AuthContext?, 130 + retryCount: Int = 0 131 + ) async throws { 132 + var req = request 133 + try await applyAuth(&req, auth: auth) 134 + 135 + let (data, response) = try await session.data(for: req) 136 + guard let httpResponse = response as? HTTPURLResponse else { return } 137 + 138 + if httpResponse.statusCode == 400, 139 + let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce"), 140 + retryCount < 2 { 141 + logger.info("DPoP nonce required (void), retrying") 142 + var updatedAuth = auth 143 + updatedAuth?.nonce = nonce 144 + var retryReq = request 145 + try await applyAuth(&retryReq, auth: updatedAuth) 146 + let (_, retryResponse) = try await session.data(for: retryReq) 147 + guard let retryHttp = retryResponse as? HTTPURLResponse else { return } 148 + if retryHttp.statusCode == 401 { throw XRPCError.unauthorized } 149 + guard (200...299).contains(retryHttp.statusCode) else { 150 + throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil) 151 + } 152 + return 153 + } 154 + 155 + if httpResponse.statusCode == 401 { 156 + // Try once more with DPoP-Nonce from response if present 157 + if let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce"), retryCount < 2 { 158 + logger.info("401 with nonce, retrying") 159 + var updatedAuth = auth 160 + updatedAuth?.nonce = nonce 161 + var retryReq = request 162 + try await applyAuth(&retryReq, auth: updatedAuth) 163 + let (_, retryResponse) = try await session.data(for: retryReq) 164 + guard let retryHttp = retryResponse as? HTTPURLResponse else { return } 165 + if retryHttp.statusCode == 401 { throw XRPCError.unauthorized } 166 + guard (200...299).contains(retryHttp.statusCode) else { 167 + throw XRPCError.httpError(statusCode: retryHttp.statusCode, body: nil) 168 + } 169 + return 170 + } 171 + throw XRPCError.unauthorized 172 + } 173 + 174 + guard (200...299).contains(httpResponse.statusCode) else { 175 + logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")") 176 + throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data) 177 + } 178 + } 179 + 180 + private func execute<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T { 181 + let (data, response) = try await session.data(for: request) 182 + guard let httpResponse = response as? HTTPURLResponse else { 183 + throw XRPCError.httpError(statusCode: 0, body: data) 184 + } 185 + 186 + // Check for DPoP nonce requirement 187 + if httpResponse.statusCode == 400, 188 + let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 189 + throw XRPCError.dpopNonceRequired(nonce: nonce) 190 + } 191 + 192 + if httpResponse.statusCode == 401 { 193 + let body = String(data: data, encoding: .utf8) ?? "" 194 + let nonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") 195 + let wwwAuth = httpResponse.value(forHTTPHeaderField: "WWW-Authenticate") 196 + logger.error("401: body=\(body), nonce=\(nonce ?? "nil"), wwwAuth=\(wwwAuth ?? "nil")") 197 + if let nonce { 198 + throw XRPCError.dpopNonceRequired(nonce: nonce) 199 + } 200 + throw XRPCError.unauthorized 201 + } 202 + 203 + guard (200...299).contains(httpResponse.statusCode) else { 204 + logger.error("HTTP \(httpResponse.statusCode): \(String(data: data, encoding: .utf8) ?? "")") 205 + throw XRPCError.httpError(statusCode: httpResponse.statusCode, body: data) 206 + } 207 + 208 + do { 209 + return try decoder.decode(type, from: data) 210 + } catch { 211 + throw XRPCError.decodingError(error) 212 + } 213 + } 214 + 215 + private func applyAuth(_ request: inout URLRequest, auth: AuthContext?) async throws { 216 + guard let auth else { return } 217 + request.setValue("DPoP \(auth.accessToken)", forHTTPHeaderField: "Authorization") 218 + let proof = try await auth.dpop.createProof( 219 + httpMethod: request.httpMethod ?? "GET", 220 + url: request.url!, 221 + accessToken: auth.accessToken, 222 + nonce: auth.nonce 223 + ) 224 + request.setValue(proof, forHTTPHeaderField: "DPoP") 225 + } 226 + } 227 + 228 + /// Context for authenticated requests. 229 + struct AuthContext: Sendable { 230 + let accessToken: String 231 + let dpop: DPoP 232 + var nonce: String? 233 + } 234 + 235 + struct UploadBlobResponse: Codable, Sendable { 236 + let blob: BlobRef 237 + }
Grain/Assets.xcassets/AppIcon.appiconset/1024.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/114.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/120.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/152.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/167.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/180.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/29.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/40.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/57.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/58.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/60.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/76.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/80.png

This is a binary file and will not be displayed.

Grain/Assets.xcassets/AppIcon.appiconset/87.png

This is a binary file and will not be displayed.

+110
Grain/Assets.xcassets/AppIcon.appiconset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "120.png", 5 + "idiom" : "iphone", 6 + "scale" : "2x", 7 + "size" : "60x60" 8 + }, 9 + { 10 + "filename" : "180.png", 11 + "idiom" : "iphone", 12 + "scale" : "3x", 13 + "size" : "60x60" 14 + }, 15 + { 16 + "filename" : "80.png", 17 + "idiom" : "iphone", 18 + "scale" : "2x", 19 + "size" : "40x40" 20 + }, 21 + { 22 + "filename" : "120.png", 23 + "idiom" : "iphone", 24 + "scale" : "3x", 25 + "size" : "40x40" 26 + }, 27 + { 28 + "filename" : "58.png", 29 + "idiom" : "iphone", 30 + "scale" : "2x", 31 + "size" : "29x29" 32 + }, 33 + { 34 + "filename" : "87.png", 35 + "idiom" : "iphone", 36 + "scale" : "3x", 37 + "size" : "29x29" 38 + }, 39 + { 40 + "filename" : "40.png", 41 + "idiom" : "iphone", 42 + "scale" : "2x", 43 + "size" : "20x20" 44 + }, 45 + { 46 + "filename" : "60.png", 47 + "idiom" : "iphone", 48 + "scale" : "3x", 49 + "size" : "20x20" 50 + }, 51 + { 52 + "filename" : "76.png", 53 + "idiom" : "ipad", 54 + "scale" : "1x", 55 + "size" : "76x76" 56 + }, 57 + { 58 + "filename" : "152.png", 59 + "idiom" : "ipad", 60 + "scale" : "2x", 61 + "size" : "76x76" 62 + }, 63 + { 64 + "filename" : "167.png", 65 + "idiom" : "ipad", 66 + "scale" : "2x", 67 + "size" : "83.5x83.5" 68 + }, 69 + { 70 + "filename" : "80.png", 71 + "idiom" : "ipad", 72 + "scale" : "2x", 73 + "size" : "40x40" 74 + }, 75 + { 76 + "filename" : "40.png", 77 + "idiom" : "ipad", 78 + "scale" : "1x", 79 + "size" : "40x40" 80 + }, 81 + { 82 + "filename" : "58.png", 83 + "idiom" : "ipad", 84 + "scale" : "2x", 85 + "size" : "29x29" 86 + }, 87 + { 88 + "filename" : "29.png", 89 + "idiom" : "ipad", 90 + "scale" : "1x", 91 + "size" : "29x29" 92 + }, 93 + { 94 + "filename" : "40.png", 95 + "idiom" : "ipad", 96 + "scale" : "2x", 97 + "size" : "20x20" 98 + }, 99 + { 100 + "filename" : "1024.png", 101 + "idiom" : "ios-marketing", 102 + "scale" : "1x", 103 + "size" : "1024x1024" 104 + } 105 + ], 106 + "info" : { 107 + "author" : "xcode", 108 + "version" : 1 109 + } 110 + }
+6
Grain/Assets.xcassets/Contents.json
··· 1 + { 2 + "info" : { 3 + "author" : "xcode", 4 + "version" : 1 5 + } 6 + }
+19
Grain/GrainApp.swift
··· 1 + import SwiftUI 2 + 3 + @main 4 + struct GrainApp: App { 5 + @State private var authManager = AuthManager() 6 + 7 + var body: some Scene { 8 + WindowGroup { 9 + if authManager.isAuthenticated { 10 + MainTabView() 11 + .environment(authManager) 12 + } else { 13 + LoginView() 14 + .environment(authManager) 15 + } 16 + } 17 + .environment(authManager) 18 + } 19 + }
+47
Grain/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleDevelopmentRegion</key> 6 + <string>$(DEVELOPMENT_LANGUAGE)</string> 7 + <key>CFBundleExecutable</key> 8 + <string>$(EXECUTABLE_NAME)</string> 9 + <key>CFBundleIconName</key> 10 + <string>AppIcon</string> 11 + <key>CFBundleIdentifier</key> 12 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 13 + <key>CFBundleInfoDictionaryVersion</key> 14 + <string>6.0</string> 15 + <key>CFBundleName</key> 16 + <string>$(PRODUCT_NAME)</string> 17 + <key>CFBundlePackageType</key> 18 + <string>APPL</string> 19 + <key>CFBundleShortVersionString</key> 20 + <string>$(MARKETING_VERSION)</string> 21 + <key>CFBundleURLTypes</key> 22 + <array> 23 + <dict> 24 + <key>CFBundleURLSchemes</key> 25 + <array> 26 + <string>grain</string> 27 + </array> 28 + </dict> 29 + </array> 30 + <key>CFBundleVersion</key> 31 + <string>$(CURRENT_PROJECT_VERSION)</string> 32 + <key>NSAppTransportSecurity</key> 33 + <dict> 34 + <key>NSAllowsLocalNetworking</key> 35 + <true/> 36 + </dict> 37 + <key>UILaunchScreen</key> 38 + <dict/> 39 + <key>UISupportedInterfaceOrientations</key> 40 + <array> 41 + <string>UIInterfaceOrientationPortrait</string> 42 + <string>UIInterfaceOrientationPortraitUpsideDown</string> 43 + <string>UIInterfaceOrientationLandscapeLeft</string> 44 + <string>UIInterfaceOrientationLandscapeRight</string> 45 + </array> 46 + </dict> 47 + </plist>
+10
Grain/Models/Common/AspectRatio.swift
··· 1 + import Foundation 2 + 3 + struct AspectRatio: Codable, Sendable { 4 + let width: Int 5 + let height: Int 6 + 7 + var ratio: Double { 8 + Double(width) / Double(height) 9 + } 10 + }
+47
Grain/Models/Common/BlobRef.swift
··· 1 + import Foundation 2 + 3 + /// AT Protocol blob reference returned from uploadBlob 4 + struct BlobRef: Codable, Sendable { 5 + let type: String? 6 + let ref: BlobLink? 7 + let mimeType: String? 8 + let size: Int? 9 + 10 + enum CodingKeys: String, CodingKey { 11 + case type = "$type" 12 + case ref 13 + case mimeType 14 + case size 15 + } 16 + 17 + struct BlobLink: Codable, Sendable { 18 + let link: String 19 + 20 + enum CodingKeys: String, CodingKey { 21 + case link = "$link" 22 + } 23 + } 24 + } 25 + 26 + /// Label definition (com.atproto.label.defs#label) 27 + struct ATLabel: Codable, Sendable { 28 + let src: String? 29 + let uri: String? 30 + let val: String? 31 + let cts: String? 32 + } 33 + 34 + /// Self-label values (com.atproto.label.defs#selfLabels) 35 + struct SelfLabels: Codable, Sendable { 36 + let type: String? 37 + let values: [SelfLabel]? 38 + 39 + enum CodingKeys: String, CodingKey { 40 + case type = "$type" 41 + case values 42 + } 43 + } 44 + 45 + struct SelfLabel: Codable, Sendable { 46 + let val: String 47 + }
+58
Grain/Models/Common/Facet.swift
··· 1 + import Foundation 2 + 3 + /// Rich text annotation (app.bsky.richtext.facet) 4 + struct Facet: Codable, Sendable { 5 + let index: ByteSlice 6 + let features: [FacetFeature] 7 + 8 + struct ByteSlice: Codable, Sendable { 9 + let byteStart: Int 10 + let byteEnd: Int 11 + } 12 + } 13 + 14 + enum FacetFeature: Codable, Sendable { 15 + case mention(did: String) 16 + case link(uri: String) 17 + case tag(tag: String) 18 + 19 + enum CodingKeys: String, CodingKey { 20 + case type = "$type" 21 + case did 22 + case uri 23 + case tag 24 + } 25 + 26 + init(from decoder: Decoder) throws { 27 + let container = try decoder.container(keyedBy: CodingKeys.self) 28 + let type = try container.decode(String.self, forKey: .type) 29 + switch type { 30 + case "app.bsky.richtext.facet#mention": 31 + let did = try container.decode(String.self, forKey: .did) 32 + self = .mention(did: did) 33 + case "app.bsky.richtext.facet#link": 34 + let uri = try container.decode(String.self, forKey: .uri) 35 + self = .link(uri: uri) 36 + case "app.bsky.richtext.facet#tag": 37 + let tag = try container.decode(String.self, forKey: .tag) 38 + self = .tag(tag: tag) 39 + default: 40 + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown facet type: \(type)") 41 + } 42 + } 43 + 44 + func encode(to encoder: Encoder) throws { 45 + var container = encoder.container(keyedBy: CodingKeys.self) 46 + switch self { 47 + case .mention(let did): 48 + try container.encode("app.bsky.richtext.facet#mention", forKey: .type) 49 + try container.encode(did, forKey: .did) 50 + case .link(let uri): 51 + try container.encode("app.bsky.richtext.facet#link", forKey: .type) 52 + try container.encode(uri, forKey: .uri) 53 + case .tag(let tag): 54 + try container.encode("app.bsky.richtext.facet#tag", forKey: .type) 55 + try container.encode(tag, forKey: .tag) 56 + } 57 + } 58 + }
+25
Grain/Models/Common/Location.swift
··· 1 + import Foundation 2 + 3 + /// H3-encoded location (community.lexicon.location.hthree) 4 + struct H3Location: Codable, Sendable { 5 + let value: String 6 + var name: String? 7 + } 8 + 9 + /// Street address (community.lexicon.location.address) 10 + struct Address: Codable, Sendable { 11 + let country: String 12 + var postalCode: String? 13 + var region: String? 14 + var locality: String? 15 + var street: String? 16 + var name: String? 17 + } 18 + 19 + /// Geo coordinate (community.lexicon.location.geo) 20 + struct GeoLocation: Codable, Sendable { 21 + let latitude: String 22 + let longitude: String 23 + var altitude: String? 24 + var name: String? 25 + }
+11
Grain/Models/Records/Comment.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.comment record 4 + struct CommentRecord: Codable, Sendable { 5 + let text: String 6 + var facets: [Facet]? 7 + let subject: String 8 + var focus: String? 9 + var replyTo: String? 10 + let createdAt: String 11 + }
+21
Grain/Models/Records/Gallery.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.gallery record 4 + struct GalleryRecord: Codable, Sendable { 5 + let title: String 6 + var description: String? 7 + var facets: [Facet]? 8 + var labels: SelfLabels? 9 + var location: H3Location? 10 + var address: Address? 11 + var updatedAt: String? 12 + let createdAt: String 13 + } 14 + 15 + /// social.grain.gallery.item record 16 + struct GalleryItemRecord: Codable, Sendable { 17 + let createdAt: String 18 + let gallery: String 19 + let item: String 20 + var position: Int? 21 + }
+26
Grain/Models/Records/Photo.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.photo record 4 + struct PhotoRecord: Codable, Sendable { 5 + let photo: BlobRef 6 + var alt: String? 7 + let aspectRatio: AspectRatio 8 + let createdAt: String 9 + } 10 + 11 + /// social.grain.photo.exif record 12 + /// Integer values are scaled by 1,000,000 to accommodate decimals. 13 + struct PhotoExifRecord: Codable, Sendable { 14 + let photo: String 15 + let createdAt: String 16 + var dateTimeOriginal: String? 17 + var exposureTime: Int? 18 + var fNumber: Int? 19 + var flash: String? 20 + var focalLengthIn35mmFormat: Int? 21 + var iSO: Int? 22 + var lensMake: String? 23 + var lensModel: String? 24 + var make: String? 25 + var model: String? 26 + }
+21
Grain/Models/Records/Social.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.favorite record 4 + struct FavoriteRecord: Codable, Sendable { 5 + let createdAt: String 6 + let subject: String 7 + } 8 + 9 + /// social.grain.graph.follow record 10 + struct FollowRecord: Codable, Sendable { 11 + let subject: String 12 + let createdAt: String 13 + } 14 + 15 + /// social.grain.actor.profile record 16 + struct ActorProfileRecord: Codable, Sendable { 17 + var displayName: String? 18 + var description: String? 19 + var avatar: BlobRef? 20 + var createdAt: String? 21 + }
+10
Grain/Models/Records/Story.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.story record 4 + struct StoryRecord: Codable, Sendable { 5 + let media: BlobRef 6 + let aspectRatio: AspectRatio 7 + var location: H3Location? 8 + var address: Address? 9 + let createdAt: String 10 + }
+17
Grain/Models/Views/CommentModels.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.comment.defs#commentView 4 + struct GrainComment: Codable, Sendable, Identifiable { 5 + let uri: String 6 + let cid: String 7 + let author: GrainProfile 8 + var record: AnyCodable? 9 + let text: String 10 + var facets: [Facet]? 11 + var subject: AnyCodable? 12 + var focus: AnyCodable? 13 + var replyTo: String? 14 + let createdAt: String 15 + 16 + var id: String { uri } 17 + }
+35
Grain/Models/Views/GalleryModels.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.gallery.defs#galleryView 4 + struct GrainGallery: Codable, Sendable, Identifiable { 5 + let uri: String 6 + let cid: String 7 + var title: String? 8 + var description: String? 9 + var cameras: [String]? 10 + var location: H3Location? 11 + var address: Address? 12 + var facets: [Facet]? 13 + let creator: GrainProfile 14 + var record: AnyCodable? 15 + var items: [GrainPhoto]? 16 + var favCount: Int? 17 + var commentCount: Int? 18 + var labels: [ATLabel]? 19 + var createdAt: String? 20 + let indexedAt: String 21 + var viewer: GalleryViewerState? 22 + var crossPost: CrossPostInfo? 23 + 24 + var id: String { uri } 25 + } 26 + 27 + /// social.grain.gallery.defs#viewerState 28 + struct GalleryViewerState: Codable, Sendable { 29 + var fav: String? 30 + } 31 + 32 + /// social.grain.gallery.defs#crossPostInfo 33 + struct CrossPostInfo: Codable, Sendable { 34 + let url: String 35 + }
+30
Grain/Models/Views/NotificationModels.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.unspecced.getNotifications#notificationItem 4 + struct GrainNotification: Codable, Sendable, Identifiable { 5 + let uri: String 6 + let reason: String 7 + let createdAt: String 8 + let author: GrainProfile 9 + var galleryUri: String? 10 + var galleryTitle: String? 11 + var galleryThumb: String? 12 + var commentText: String? 13 + var replyToText: String? 14 + 15 + var id: String { uri } 16 + 17 + var reasonType: NotificationReason { 18 + NotificationReason(rawValue: reason) ?? .unknown 19 + } 20 + } 21 + 22 + enum NotificationReason: String, Sendable { 23 + case galleryFavorite = "gallery-favorite" 24 + case galleryComment = "gallery-comment" 25 + case galleryCommentMention = "gallery-comment-mention" 26 + case galleryMention = "gallery-mention" 27 + case reply 28 + case follow 29 + case unknown 30 + }
+41
Grain/Models/Views/PhotoModels.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.photo.defs#photoView 4 + struct GrainPhoto: Codable, Sendable, Identifiable { 5 + let uri: String 6 + let cid: String 7 + let thumb: String 8 + let fullsize: String 9 + var alt: String? 10 + let aspectRatio: AspectRatio 11 + var exif: GrainExif? 12 + var gallery: PhotoGalleryState? 13 + 14 + var id: String { uri } 15 + } 16 + 17 + /// social.grain.photo.defs#exifView 18 + struct GrainExif: Codable, Sendable { 19 + let uri: String 20 + let cid: String 21 + let photo: String 22 + var record: AnyCodable? 23 + let createdAt: String 24 + var dateTimeOriginal: String? 25 + var exposureTime: String? 26 + var fNumber: String? 27 + var flash: String? 28 + var focalLengthIn35mmFormat: String? 29 + var iSO: Int? 30 + var lensMake: String? 31 + var lensModel: String? 32 + var make: String? 33 + var model: String? 34 + } 35 + 36 + /// social.grain.photo.defs#galleryState 37 + struct PhotoGalleryState: Codable, Sendable { 38 + let item: String 39 + let itemCreatedAt: String 40 + let itemPosition: Int 41 + }
+41
Grain/Models/Views/ProfileModels.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.actor.defs#profileView 4 + struct GrainProfile: Codable, Sendable, Identifiable { 5 + let cid: String 6 + let did: String 7 + let handle: String 8 + var displayName: String? 9 + var description: String? 10 + var labels: [ATLabel]? 11 + var avatar: String? 12 + var createdAt: String? 13 + 14 + var id: String { did } 15 + } 16 + 17 + /// social.grain.actor.defs#profileViewDetailed 18 + struct GrainProfileDetailed: Codable, Sendable, Identifiable { 19 + let cid: String 20 + let did: String 21 + let handle: String 22 + var displayName: String? 23 + var description: String? 24 + var avatar: String? 25 + var cameras: [String]? 26 + var followersCount: Int? 27 + var followsCount: Int? 28 + var galleryCount: Int? 29 + var indexedAt: String? 30 + var createdAt: String? 31 + var viewer: ActorViewerState? 32 + var labels: [ATLabel]? 33 + 34 + var id: String { did } 35 + } 36 + 37 + /// social.grain.actor.defs#viewerState 38 + struct ActorViewerState: Codable, Sendable { 39 + var following: String? 40 + var followedBy: String? 41 + }
+27
Grain/Models/Views/StoryModels.swift
··· 1 + import Foundation 2 + 3 + /// social.grain.story.defs#storyView 4 + struct GrainStory: Codable, Sendable, Identifiable { 5 + let uri: String 6 + let cid: String 7 + let creator: GrainProfile 8 + let thumb: String 9 + let fullsize: String 10 + let aspectRatio: AspectRatio 11 + var location: H3Location? 12 + var address: Address? 13 + let createdAt: String 14 + var labels: [ATLabel]? 15 + var crossPost: CrossPostInfo? 16 + 17 + var id: String { uri } 18 + } 19 + 20 + /// social.grain.unspecced.getStoryAuthors#storyAuthor 21 + struct GrainStoryAuthor: Codable, Sendable, Identifiable { 22 + let profile: GrainProfile 23 + let storyCount: Int 24 + let latestAt: String 25 + 26 + var id: String { profile.did } 27 + }
+64
Grain/Utilities/AnyCodable.swift
··· 1 + import Foundation 2 + 3 + /// Type-erased Codable wrapper for AT Protocol's "unknown" typed fields. 4 + struct AnyCodable: Codable, Sendable { 5 + private enum Storage: Sendable { 6 + case null 7 + case bool(Bool) 8 + case int(Int) 9 + case double(Double) 10 + case string(String) 11 + case array([AnyCodable]) 12 + case dict([String: AnyCodable]) 13 + } 14 + 15 + private let storage: Storage 16 + 17 + init(_ value: some Sendable) { 18 + switch value { 19 + case let b as Bool: storage = .bool(b) 20 + case let i as Int: storage = .int(i) 21 + case let d as Double: storage = .double(d) 22 + case let s as String: storage = .string(s) 23 + case let a as [AnyCodable]: storage = .array(a) 24 + case let d as [String: AnyCodable]: storage = .dict(d) 25 + case let d as [String: String]: 26 + storage = .dict(d.mapValues { AnyCodable($0) }) 27 + default: storage = .null 28 + } 29 + } 30 + 31 + init(from decoder: Decoder) throws { 32 + let container = try decoder.singleValueContainer() 33 + if container.decodeNil() { 34 + storage = .null 35 + } else if let bool = try? container.decode(Bool.self) { 36 + storage = .bool(bool) 37 + } else if let int = try? container.decode(Int.self) { 38 + storage = .int(int) 39 + } else if let double = try? container.decode(Double.self) { 40 + storage = .double(double) 41 + } else if let string = try? container.decode(String.self) { 42 + storage = .string(string) 43 + } else if let array = try? container.decode([AnyCodable].self) { 44 + storage = .array(array) 45 + } else if let dict = try? container.decode([String: AnyCodable].self) { 46 + storage = .dict(dict) 47 + } else { 48 + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode AnyCodable") 49 + } 50 + } 51 + 52 + func encode(to encoder: Encoder) throws { 53 + var container = encoder.singleValueContainer() 54 + switch storage { 55 + case .null: try container.encodeNil() 56 + case .bool(let v): try container.encode(v) 57 + case .int(let v): try container.encode(v) 58 + case .double(let v): try container.encode(v) 59 + case .string(let v): try container.encode(v) 60 + case .array(let v): try container.encode(v) 61 + case .dict(let v): try container.encode(v) 62 + } 63 + } 64 + }
+14
Grain/Utilities/LiquidGlass.swift
··· 1 + import SwiftUI 2 + 3 + extension View { 4 + /// Applies iOS 26 Liquid Glass effect. 5 + func liquidGlass() -> some View { 6 + self.glassEffect(.regular.interactive()) 7 + } 8 + 9 + /// Applies iOS 26 Liquid Glass effect in a circular shape. 10 + func liquidGlassCircle() -> some View { 11 + self.clipShape(Circle()) 12 + .glassEffect(.regular.interactive()) 13 + } 14 + }
+92
Grain/ViewModels/FeedViewModel.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class FeedViewModel { 6 + var galleries: [GrainGallery] = [] 7 + var isLoading = false 8 + var error: Error? 9 + 10 + private var cursor: String? 11 + private var hasMore = true 12 + private let client: XRPCClient 13 + private let feedName: String 14 + private let actor: String? 15 + private let camera: String? 16 + private let location: String? 17 + private let tag: String? 18 + 19 + init( 20 + client: XRPCClient, 21 + feedName: String, 22 + actor: String? = nil, 23 + camera: String? = nil, 24 + location: String? = nil, 25 + tag: String? = nil 26 + ) { 27 + self.client = client 28 + self.feedName = feedName 29 + self.actor = actor 30 + self.camera = camera 31 + self.location = location 32 + self.tag = tag 33 + } 34 + 35 + convenience init(client: XRPCClient, pinnedFeed: PinnedFeed, userDID: String? = nil) { 36 + self.init( 37 + client: client, 38 + feedName: pinnedFeed.feedName, 39 + actor: pinnedFeed.id == "following" ? userDID : nil, 40 + camera: pinnedFeed.type == "camera" ? pinnedFeed.feedValue : nil, 41 + location: pinnedFeed.type == "location" ? pinnedFeed.feedValue : nil, 42 + tag: pinnedFeed.type == "hashtag" ? pinnedFeed.feedValue : nil 43 + ) 44 + } 45 + 46 + func loadInitial(auth: AuthContext? = nil) async { 47 + guard !isLoading else { return } 48 + isLoading = true 49 + error = nil 50 + cursor = nil 51 + hasMore = true 52 + 53 + do { 54 + let response = try await client.getFeed( 55 + feed: feedName, 56 + actor: actor, 57 + camera: camera, 58 + location: location, 59 + tag: tag, 60 + auth: auth 61 + ) 62 + galleries = response.items ?? [] 63 + cursor = response.cursor 64 + hasMore = response.cursor != nil 65 + } catch { 66 + self.error = error 67 + } 68 + isLoading = false 69 + } 70 + 71 + func loadMore(auth: AuthContext? = nil) async { 72 + guard !isLoading, hasMore, let cursor else { return } 73 + isLoading = true 74 + 75 + do { 76 + let response = try await client.getFeed( 77 + feed: feedName, 78 + cursor: cursor, 79 + actor: actor, 80 + camera: camera, 81 + location: location, 82 + auth: auth 83 + ) 84 + galleries.append(contentsOf: response.items ?? []) 85 + self.cursor = response.cursor 86 + hasMore = response.cursor != nil 87 + } catch { 88 + self.error = error 89 + } 90 + isLoading = false 91 + } 92 + }
+73
Grain/ViewModels/GalleryDetailViewModel.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class GalleryDetailViewModel { 6 + var gallery: GrainGallery? 7 + var comments: [GrainComment] = [] 8 + var isLoading = false 9 + var error: Error? 10 + 11 + private var commentCursor: String? 12 + private var hasMoreComments = true 13 + private let client: XRPCClient 14 + 15 + init(client: XRPCClient) { 16 + self.client = client 17 + } 18 + 19 + func load(uri: String, auth: AuthContext? = nil) async { 20 + isLoading = true 21 + error = nil 22 + 23 + do { 24 + async let galleryResponse = client.getGallery(uri: uri, auth: auth) 25 + async let commentsResponse = client.getGalleryThread(gallery: uri, auth: auth) 26 + 27 + let (g, c) = try await (galleryResponse, commentsResponse) 28 + gallery = g.gallery 29 + comments = c.comments 30 + commentCursor = c.cursor 31 + hasMoreComments = c.cursor != nil 32 + } catch { 33 + self.error = error 34 + } 35 + isLoading = false 36 + } 37 + 38 + func loadMoreComments(galleryUri: String, auth: AuthContext? = nil) async { 39 + guard !isLoading, hasMoreComments, let cursor = commentCursor else { return } 40 + isLoading = true 41 + 42 + do { 43 + let response = try await client.getGalleryThread(gallery: galleryUri, cursor: cursor, auth: auth) 44 + comments.append(contentsOf: response.comments) 45 + commentCursor = response.cursor 46 + hasMoreComments = response.cursor != nil 47 + } catch { 48 + self.error = error 49 + } 50 + isLoading = false 51 + } 52 + 53 + func toggleFavorite(auth: AuthContext?) async { 54 + guard let gallery, let auth else { return } 55 + if let favUri = gallery.viewer?.fav { 56 + // Unfavorite 57 + let rkey = favUri.split(separator: "/").last.map(String.init) ?? "" 58 + try? await client.deleteRecord(collection: "social.grain.favorite", rkey: rkey, auth: auth) 59 + self.gallery?.viewer?.fav = nil 60 + self.gallery?.favCount = (self.gallery?.favCount ?? 1) - 1 61 + } else { 62 + // Favorite 63 + let record = AnyCodable([ 64 + "subject": gallery.uri, 65 + "createdAt": ISO8601DateFormatter().string(from: Date()) 66 + ]) 67 + let repo = TokenStorage.userDID ?? "" 68 + let response = try? await client.createRecord(collection: "social.grain.favorite", repo: repo, record: record, auth: auth) 69 + self.gallery?.viewer = GalleryViewerState(fav: response?.uri) 70 + self.gallery?.favCount = (self.gallery?.favCount ?? 0) + 1 71 + } 72 + } 73 + }
+59
Grain/ViewModels/NotificationsViewModel.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class NotificationsViewModel { 6 + var notifications: [GrainNotification] = [] 7 + var unseenCount: Int = 0 8 + var isLoading = false 9 + var error: Error? 10 + 11 + private var cursor: String? 12 + private var hasMore = true 13 + private let client: XRPCClient 14 + 15 + init(client: XRPCClient) { 16 + self.client = client 17 + } 18 + 19 + func loadInitial(auth: AuthContext? = nil) async { 20 + guard !isLoading else { return } 21 + isLoading = true 22 + error = nil 23 + cursor = nil 24 + hasMore = true 25 + 26 + do { 27 + let response = try await client.getNotifications(auth: auth) 28 + notifications = response.notifications 29 + unseenCount = response.unseenCount ?? 0 30 + cursor = response.cursor 31 + hasMore = response.cursor != nil 32 + } catch { 33 + self.error = error 34 + } 35 + isLoading = false 36 + } 37 + 38 + func loadMore(auth: AuthContext? = nil) async { 39 + guard !isLoading, hasMore, let cursor else { return } 40 + isLoading = true 41 + 42 + do { 43 + let response = try await client.getNotifications(cursor: cursor, auth: auth) 44 + notifications.append(contentsOf: response.notifications) 45 + self.cursor = response.cursor 46 + hasMore = response.cursor != nil 47 + } catch { 48 + self.error = error 49 + } 50 + isLoading = false 51 + } 52 + 53 + func fetchUnseenCount(auth: AuthContext? = nil) async { 54 + do { 55 + let response = try await client.getNotifications(countOnly: true, auth: auth) 56 + unseenCount = response.unseenCount ?? 0 57 + } catch {} 58 + } 59 + }
+74
Grain/ViewModels/ProfileDetailViewModel.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class ProfileDetailViewModel { 6 + var profile: GrainProfileDetailed? 7 + var galleries: [GrainGallery] = [] 8 + var stories: [GrainStory] = [] 9 + var isLoading = false 10 + var error: Error? 11 + 12 + private var galleryCursor: String? 13 + private var hasMoreGalleries = true 14 + private let client: XRPCClient 15 + 16 + init(client: XRPCClient) { 17 + self.client = client 18 + } 19 + 20 + func load(did: String, auth: AuthContext? = nil) async { 21 + isLoading = true 22 + error = nil 23 + 24 + do { 25 + async let profileResponse = client.getActorProfile(actor: did, auth: auth) 26 + async let feedResponse = client.getFeed(feed: "actor", actor: did, auth: auth) 27 + async let storiesResponse = client.getStories(actor: did, auth: auth) 28 + 29 + let (p, f, s) = try await (profileResponse, feedResponse, storiesResponse) 30 + profile = p 31 + galleries = f.items ?? [] 32 + galleryCursor = f.cursor 33 + hasMoreGalleries = f.cursor != nil 34 + stories = s.stories 35 + } catch { 36 + self.error = error 37 + } 38 + isLoading = false 39 + } 40 + 41 + func loadMoreGalleries(did: String, auth: AuthContext? = nil) async { 42 + guard !isLoading, hasMoreGalleries, let cursor = galleryCursor else { return } 43 + isLoading = true 44 + 45 + do { 46 + let response = try await client.getFeed(feed: "actor", cursor: cursor, actor: did, auth: auth) 47 + galleries.append(contentsOf: response.items ?? []) 48 + galleryCursor = response.cursor 49 + hasMoreGalleries = response.cursor != nil 50 + } catch { 51 + self.error = error 52 + } 53 + isLoading = false 54 + } 55 + 56 + func toggleFollow(auth: AuthContext?) async { 57 + guard let profile, let auth else { return } 58 + if let followUri = profile.viewer?.following { 59 + let rkey = followUri.split(separator: "/").last.map(String.init) ?? "" 60 + try? await client.deleteRecord(collection: "social.grain.graph.follow", rkey: rkey, auth: auth) 61 + self.profile?.viewer?.following = nil 62 + self.profile?.followersCount = (self.profile?.followersCount ?? 1) - 1 63 + } else { 64 + let record = AnyCodable([ 65 + "subject": profile.did, 66 + "createdAt": ISO8601DateFormatter().string(from: Date()) 67 + ]) 68 + let repo = TokenStorage.userDID ?? "" 69 + let response = try? await client.createRecord(collection: "social.grain.graph.follow", repo: repo, record: record, auth: auth) 70 + self.profile?.viewer?.following = response?.uri 71 + self.profile?.followersCount = (self.profile?.followersCount ?? 0) + 1 72 + } 73 + } 74 + }
+52
Grain/ViewModels/SearchViewModel.swift
··· 1 + import Foundation 2 + 3 + @Observable 4 + @MainActor 5 + final class SearchViewModel { 6 + var galleryResults: [GrainGallery] = [] 7 + var profileResults: [ProfileSearchResult] = [] 8 + var locations: [LocationItem] = [] 9 + var cameras: [CameraItem] = [] 10 + var isSearching = false 11 + var searchText = "" 12 + var selectedTab: SearchTab = .galleries 13 + 14 + private let client: XRPCClient 15 + 16 + enum SearchTab: String, CaseIterable { 17 + case galleries = "Galleries" 18 + case profiles = "Profiles" 19 + } 20 + 21 + init(client: XRPCClient) { 22 + self.client = client 23 + } 24 + 25 + func loadDiscovery(auth: AuthContext? = nil) async { 26 + do { 27 + async let locationsResponse = client.getLocations(auth: auth) 28 + async let camerasResponse = client.getCameras(auth: auth) 29 + let (l, c) = try await (locationsResponse, camerasResponse) 30 + locations = l.locations ?? [] 31 + cameras = c.cameras ?? [] 32 + } catch {} 33 + } 34 + 35 + func search(auth: AuthContext? = nil) async { 36 + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) 37 + guard !query.isEmpty else { return } 38 + isSearching = true 39 + 40 + do { 41 + switch selectedTab { 42 + case .galleries: 43 + let response = try await client.searchGalleries(query: query, auth: auth) 44 + galleryResults = response.items ?? [] 45 + case .profiles: 46 + let response = try await client.searchProfiles(query: query, auth: auth) 47 + profileResults = response.items ?? [] 48 + } 49 + } catch {} 50 + isSearching = false 51 + } 52 + }
+34
Grain/Views/Components/AvatarView.swift
··· 1 + import SwiftUI 2 + import NukeUI 3 + 4 + struct AvatarView: View { 5 + let url: String? 6 + var size: CGFloat = 32 7 + 8 + var body: some View { 9 + if let url, let imageURL = URL(string: url) { 10 + LazyImage(url: imageURL) { state in 11 + if let image = state.image { 12 + image.resizable() 13 + } else { 14 + fallback 15 + } 16 + } 17 + .frame(width: size, height: size) 18 + .clipShape(Circle()) 19 + } else { 20 + fallback 21 + .frame(width: size, height: size) 22 + .clipShape(Circle()) 23 + } 24 + } 25 + 26 + private var fallback: some View { 27 + ZStack { 28 + Circle().fill(.quaternary) 29 + Image(systemName: "person.fill") 30 + .font(.system(size: size * 0.45)) 31 + .foregroundStyle(.tertiary) 32 + } 33 + } 34 + }
+207
Grain/Views/Components/GalleryCardView.swift
··· 1 + import SwiftUI 2 + import NukeUI 3 + import os 4 + 5 + private let logger = Logger(subsystem: "social.grain.grain", category: "GalleryCard") 6 + 7 + struct GalleryCardView: View { 8 + @Environment(AuthManager.self) private var auth 9 + @Binding var gallery: GrainGallery 10 + let client: XRPCClient 11 + var onNavigate: () -> Void = {} 12 + @State private var isFavoriting = false 13 + @State private var currentPage = 0 14 + @State private var showingAlt = false 15 + 16 + private var isFavorited: Bool { 17 + gallery.viewer?.fav != nil 18 + } 19 + 20 + var body: some View { 21 + VStack(alignment: .leading, spacing: 0) { 22 + // Header — tappable for navigation 23 + HStack(spacing: 8) { 24 + AvatarView(url: gallery.creator.avatar, size: 32) 25 + 26 + VStack(alignment: .leading, spacing: 0) { 27 + Text(gallery.creator.displayName ?? gallery.creator.handle) 28 + .font(.subheadline.weight(.semibold)) 29 + .lineLimit(1) 30 + Text("@\(gallery.creator.handle)") 31 + .font(.caption) 32 + .foregroundStyle(.secondary) 33 + .lineLimit(1) 34 + } 35 + 36 + Spacer() 37 + } 38 + .padding(.horizontal, 12) 39 + .padding(.vertical, 8) 40 + .contentShape(Rectangle()) 41 + .onTapGesture { onNavigate() } 42 + 43 + // Photo carousel — tappable for navigation 44 + if let photos = gallery.items, !photos.isEmpty { 45 + ZStack(alignment: .bottom) { 46 + TabView(selection: $currentPage) { 47 + ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in 48 + LazyImage(url: URL(string: photo.fullsize)) { state in 49 + if let image = state.image { 50 + image 51 + .resizable() 52 + .aspectRatio(photo.aspectRatio.ratio, contentMode: .fit) 53 + } else { 54 + Rectangle() 55 + .fill(.quaternary) 56 + .aspectRatio(photo.aspectRatio.ratio, contentMode: .fit) 57 + } 58 + } 59 + .tag(index) 60 + } 61 + } 62 + .tabViewStyle(.page(indexDisplayMode: .never)) 63 + .aspectRatio(photos[currentPage].aspectRatio.ratio, contentMode: .fit) 64 + 65 + // Page indicator 66 + if photos.count > 1 { 67 + HStack(spacing: 6) { 68 + ForEach(0..<photos.count, id: \.self) { index in 69 + Circle() 70 + .fill(index == currentPage ? Color.white : Color.white.opacity(0.5)) 71 + .frame(width: 6, height: 6) 72 + } 73 + } 74 + .padding(.vertical, 8) 75 + } 76 + 77 + // Alt text overlay — centered, tap to dismiss 78 + if showingAlt, let alt = photos[currentPage].alt, !alt.isEmpty { 79 + Color.black.opacity(0.6) 80 + .frame(maxWidth: .infinity, maxHeight: .infinity) 81 + .onTapGesture { 82 + withAnimation(.easeInOut(duration: 0.2)) { 83 + showingAlt = false 84 + } 85 + } 86 + Text(alt) 87 + .font(.subheadline) 88 + .foregroundStyle(.white) 89 + .multilineTextAlignment(.center) 90 + .padding(20) 91 + .frame(maxWidth: .infinity, maxHeight: .infinity) 92 + .allowsHitTesting(false) 93 + } 94 + 95 + // ALT button — bottom right 96 + if let alt = photos[currentPage].alt, !alt.isEmpty { 97 + VStack { 98 + Spacer() 99 + HStack { 100 + Spacer() 101 + Button { 102 + withAnimation(.easeInOut(duration: 0.2)) { 103 + showingAlt.toggle() 104 + } 105 + } label: { 106 + Text("ALT") 107 + .font(.caption2.weight(.bold)) 108 + .padding(.horizontal, 6) 109 + .padding(.vertical, 3) 110 + .background(.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) 111 + .foregroundStyle(.white) 112 + } 113 + } 114 + .padding(8) 115 + } 116 + } 117 + } 118 + .onChange(of: currentPage) { showingAlt = false } 119 + } 120 + 121 + // Engagement row 122 + HStack(spacing: 16) { 123 + Button { 124 + guard !isFavoriting else { return } 125 + isFavoriting = true 126 + Task { 127 + await toggleFavorite() 128 + isFavoriting = false 129 + } 130 + } label: { 131 + Label( 132 + "\(gallery.favCount ?? 0)", 133 + systemImage: isFavorited ? "heart.fill" : "heart" 134 + ) 135 + .contentTransition(.symbolEffect(.replace)) 136 + } 137 + .foregroundStyle(isFavorited ? .red : .secondary) 138 + 139 + Label("\(gallery.commentCount ?? 0)", systemImage: "bubble.right") 140 + .foregroundStyle(.secondary) 141 + 142 + Spacer() 143 + } 144 + .font(.subheadline) 145 + .padding(.horizontal, 12) 146 + .padding(.top, 8) 147 + 148 + // Title & description — tappable for navigation 149 + VStack(alignment: .leading, spacing: 2) { 150 + Text(gallery.title ?? "") 151 + .font(.subheadline.weight(.semibold)) 152 + .lineLimit(1) 153 + 154 + if let description = gallery.description, !description.isEmpty { 155 + Text(description) 156 + .font(.caption) 157 + .foregroundStyle(.secondary) 158 + .lineLimit(2) 159 + } 160 + } 161 + .padding(.horizontal, 12) 162 + .padding(.top, 4) 163 + .padding(.bottom, 12) 164 + .contentShape(Rectangle()) 165 + .onTapGesture { onNavigate() } 166 + } 167 + } 168 + 169 + private func toggleFavorite() async { 170 + guard let authContext = auth.authContext() else { 171 + logger.error("No auth context") 172 + return 173 + } 174 + if let favUri = gallery.viewer?.fav { 175 + gallery.viewer?.fav = nil 176 + gallery.favCount = max((gallery.favCount ?? 1) - 1, 0) 177 + 178 + let rkey = favUri.split(separator: "/").last.map(String.init) ?? "" 179 + do { 180 + try await client.deleteRecord(collection: "social.grain.favorite", rkey: rkey, auth: authContext) 181 + } catch { 182 + logger.error("Unfavorite failed: \(error)") 183 + gallery.viewer?.fav = favUri 184 + gallery.favCount = (gallery.favCount ?? 0) + 1 185 + } 186 + } else { 187 + let prevViewer = gallery.viewer 188 + let prevCount = gallery.favCount 189 + gallery.viewer = GalleryViewerState(fav: "pending") 190 + gallery.favCount = (gallery.favCount ?? 0) + 1 191 + 192 + let record = AnyCodable([ 193 + "subject": gallery.uri, 194 + "createdAt": ISO8601DateFormatter().string(from: Date()), 195 + ]) 196 + let repo = TokenStorage.userDID ?? "" 197 + do { 198 + let response = try await client.createRecord(collection: "social.grain.favorite", repo: repo, record: record, auth: authContext) 199 + gallery.viewer = GalleryViewerState(fav: response.uri) 200 + } catch { 201 + logger.error("Favorite failed: \(error)") 202 + gallery.viewer = prevViewer 203 + gallery.favCount = prevCount 204 + } 205 + } 206 + } 207 + }
+149
Grain/Views/Create/CreateGalleryView.swift
··· 1 + import PhotosUI 2 + import SwiftUI 3 + 4 + struct CreateGalleryView: View { 5 + @Environment(AuthManager.self) private var auth 6 + @State private var title = "" 7 + @State private var description = "" 8 + @State private var selectedPhotos: [PhotosPickerItem] = [] 9 + @State private var photoData: [(Data, String)] = [] // (data, mimeType) 10 + @State private var isUploading = false 11 + @State private var errorMessage: String? 12 + 13 + let client: XRPCClient 14 + 15 + var body: some View { 16 + NavigationStack { 17 + Form { 18 + Section("Photos") { 19 + PhotosPicker( 20 + selection: $selectedPhotos, 21 + maxSelectionCount: 20, 22 + matching: .images 23 + ) { 24 + Label("Select Photos", systemImage: "photo.on.rectangle.angled") 25 + } 26 + 27 + if !selectedPhotos.isEmpty { 28 + Text("\(selectedPhotos.count) photo(s) selected") 29 + .font(.caption) 30 + .foregroundStyle(.secondary) 31 + } 32 + } 33 + 34 + Section("Details") { 35 + TextField("Title", text: $title) 36 + TextField("Description (optional)", text: $description, axis: .vertical) 37 + .lineLimit(3...6) 38 + } 39 + 40 + if let errorMessage { 41 + Section { 42 + Text(errorMessage) 43 + .foregroundStyle(.red) 44 + .font(.caption) 45 + } 46 + } 47 + } 48 + .navigationTitle("New Gallery") 49 + .toolbar { 50 + ToolbarItem(placement: .topBarTrailing) { 51 + Button { 52 + Task { await createGallery() } 53 + } label: { 54 + if isUploading { 55 + ProgressView() 56 + } else { 57 + Text("Post") 58 + .bold() 59 + } 60 + } 61 + .disabled(title.isEmpty || selectedPhotos.isEmpty || isUploading) 62 + } 63 + } 64 + } 65 + } 66 + 67 + private func createGallery() async { 68 + guard let authContext = auth.authContext(), let repo = auth.userDID else { return } 69 + isUploading = true 70 + errorMessage = nil 71 + 72 + do { 73 + // 1. Load photo data from picker 74 + var uploadedBlobs: [(BlobRef, AspectRatio)] = [] 75 + for item in selectedPhotos { 76 + guard let data = try await item.loadTransferable(type: Data.self) else { continue } 77 + let mimeType = "image/jpeg" 78 + let response = try await client.uploadBlob(data: data, mimeType: mimeType, auth: authContext) 79 + // TODO: Extract actual aspect ratio from image data 80 + uploadedBlobs.append((response.blob, AspectRatio(width: 4, height: 3))) 81 + } 82 + 83 + // 2. Create photo records 84 + let now = ISO8601DateFormatter().string(from: Date()) 85 + var photoUris: [String] = [] 86 + for (blob, aspectRatio) in uploadedBlobs { 87 + let photoRecord: [String: AnyCodable] = [ 88 + "$type": AnyCodable("social.grain.photo"), 89 + "photo": AnyCodable([ 90 + "$type": AnyCodable(blob.type ?? "blob"), 91 + "ref": AnyCodable(["$link": AnyCodable(blob.ref?.link ?? "")]), 92 + "mimeType": AnyCodable(blob.mimeType ?? "image/jpeg"), 93 + "size": AnyCodable(blob.size ?? 0) 94 + ] as [String: AnyCodable]), 95 + "aspectRatio": AnyCodable(["width": AnyCodable(aspectRatio.width), "height": AnyCodable(aspectRatio.height)]), 96 + "createdAt": AnyCodable(now) 97 + ] 98 + let result = try await client.createRecord( 99 + collection: "social.grain.photo", 100 + repo: repo, 101 + record: AnyCodable(photoRecord), 102 + auth: authContext 103 + ) 104 + if let uri = result.uri { photoUris.append(uri) } 105 + } 106 + 107 + // 3. Create gallery record 108 + var galleryRecord: [String: AnyCodable] = [ 109 + "$type": AnyCodable("social.grain.gallery"), 110 + "title": AnyCodable(title), 111 + "createdAt": AnyCodable(now) 112 + ] 113 + if !description.isEmpty { galleryRecord["description"] = AnyCodable(description) } 114 + let galleryResult = try await client.createRecord( 115 + collection: "social.grain.gallery", 116 + repo: repo, 117 + record: AnyCodable(galleryRecord), 118 + auth: authContext 119 + ) 120 + 121 + // 4. Create gallery items linking photos to gallery 122 + if let galleryUri = galleryResult.uri { 123 + for (index, photoUri) in photoUris.enumerated() { 124 + let itemRecord: [String: AnyCodable] = [ 125 + "$type": AnyCodable("social.grain.gallery.item"), 126 + "gallery": AnyCodable(galleryUri), 127 + "item": AnyCodable(photoUri), 128 + "position": AnyCodable(index), 129 + "createdAt": AnyCodable(now) 130 + ] 131 + _ = try await client.createRecord( 132 + collection: "social.grain.gallery.item", 133 + repo: repo, 134 + record: AnyCodable(itemRecord), 135 + auth: authContext 136 + ) 137 + } 138 + } 139 + 140 + // Reset form 141 + title = "" 142 + self.description = "" 143 + selectedPhotos = [] 144 + } catch { 145 + errorMessage = error.localizedDescription 146 + } 147 + isUploading = false 148 + } 149 + }
+122
Grain/Views/Feed/FeedView.swift
··· 1 + import SwiftUI 2 + 3 + struct FeedView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @State private var pinnedFeeds: [PinnedFeed] = PinnedFeed.defaults 6 + @State private var selectedFeedId: String = "recent" 7 + @State private var hasLoadedPreferences = false 8 + 9 + let client: XRPCClient 10 + 11 + init(client: XRPCClient) { 12 + self.client = client 13 + } 14 + 15 + var body: some View { 16 + NavigationStack { 17 + ZStack { 18 + ForEach(pinnedFeeds) { feed in 19 + if feed.id == selectedFeedId { 20 + FeedTabContent(client: client, pinnedFeed: feed, userDID: auth.userDID) 21 + } 22 + } 23 + } 24 + .navigationTitle("Grain") 25 + .navigationBarTitleDisplayMode(.inline) 26 + .safeAreaInset(edge: .top, spacing: 0) { 27 + if pinnedFeeds.count > 1 { 28 + ScrollView(.horizontal, showsIndicators: false) { 29 + HStack(spacing: 8) { 30 + ForEach(pinnedFeeds) { feed in 31 + Button { 32 + selectedFeedId = feed.id 33 + } label: { 34 + Text(feed.label) 35 + .font(.subheadline.weight(selectedFeedId == feed.id ? .bold : .regular)) 36 + .padding(.horizontal, 14) 37 + .padding(.vertical, 7) 38 + .background( 39 + selectedFeedId == feed.id 40 + ? AnyShapeStyle(.tint) 41 + : AnyShapeStyle(.quaternary), 42 + in: Capsule() 43 + ) 44 + .foregroundStyle(selectedFeedId == feed.id ? .white : .primary) 45 + } 46 + .buttonStyle(.plain) 47 + } 48 + } 49 + .padding(.horizontal) 50 + .padding(.vertical, 8) 51 + } 52 + .background(.bar) 53 + } 54 + } 55 + .task { 56 + await loadPreferences() 57 + } 58 + } 59 + } 60 + 61 + private func loadPreferences() async { 62 + guard !hasLoadedPreferences else { return } 63 + hasLoadedPreferences = true 64 + 65 + do { 66 + let response = try await client.getPreferences(auth: auth.authContext()) 67 + if let feeds = response.preferences.pinnedFeeds, !feeds.isEmpty { 68 + pinnedFeeds = feeds 69 + selectedFeedId = feeds.first?.id ?? "recent" 70 + } 71 + } catch { 72 + // Fall back to defaults, already set 73 + } 74 + } 75 + } 76 + 77 + private struct FeedTabContent: View { 78 + @Environment(AuthManager.self) private var auth 79 + @State private var viewModel: FeedViewModel 80 + @State private var selectedUri: String? 81 + let client: XRPCClient 82 + 83 + init(client: XRPCClient, pinnedFeed: PinnedFeed, userDID: String? = nil) { 84 + self.client = client 85 + _viewModel = State(initialValue: FeedViewModel(client: client, pinnedFeed: pinnedFeed, userDID: userDID)) 86 + } 87 + 88 + var body: some View { 89 + ScrollView { 90 + LazyVStack(spacing: 0) { 91 + ForEach($viewModel.galleries) { $gallery in 92 + GalleryCardView(gallery: $gallery, client: client) { 93 + selectedUri = gallery.uri 94 + } 95 + .onAppear { 96 + if gallery.id == viewModel.galleries.last?.id { 97 + Task { await viewModel.loadMore(auth: auth.authContext()) } 98 + } 99 + } 100 + 101 + Divider() 102 + } 103 + 104 + if viewModel.isLoading { 105 + ProgressView() 106 + .padding() 107 + } 108 + } 109 + } 110 + .refreshable { 111 + await viewModel.loadInitial(auth: auth.authContext()) 112 + } 113 + .navigationDestination(item: $selectedUri) { uri in 114 + GalleryDetailView(client: client, galleryUri: uri) 115 + } 116 + .task { 117 + if viewModel.galleries.isEmpty { 118 + await viewModel.loadInitial(auth: auth.authContext()) 119 + } 120 + } 121 + } 122 + }
+122
Grain/Views/Gallery/GalleryDetailView.swift
··· 1 + import SwiftUI 2 + import NukeUI 3 + 4 + struct GalleryDetailView: View { 5 + @Environment(AuthManager.self) private var auth 6 + @State private var viewModel: GalleryDetailViewModel 7 + 8 + let galleryUri: String 9 + 10 + init(client: XRPCClient, galleryUri: String) { 11 + _viewModel = State(initialValue: GalleryDetailViewModel(client: client)) 12 + self.galleryUri = galleryUri 13 + } 14 + 15 + var body: some View { 16 + ScrollView { 17 + if let gallery = viewModel.gallery { 18 + VStack(alignment: .leading, spacing: 16) { 19 + // Photos — edge to edge, respecting aspect ratio 20 + if let items = gallery.items { 21 + ForEach(items) { photo in 22 + LazyImage(url: URL(string: photo.fullsize)) { state in 23 + if let image = state.image { 24 + image 25 + .resizable() 26 + .aspectRatio(photo.aspectRatio.ratio, contentMode: .fit) 27 + } else if state.isLoading { 28 + Rectangle() 29 + .fill(.quaternary) 30 + .aspectRatio(photo.aspectRatio.ratio, contentMode: .fit) 31 + } 32 + } 33 + } 34 + } 35 + 36 + // Title & Description 37 + VStack(alignment: .leading, spacing: 8) { 38 + Text(gallery.title ?? "") 39 + .font(.title2.bold()) 40 + 41 + if let description = gallery.description, !description.isEmpty { 42 + Text(description) 43 + .font(.body) 44 + } 45 + 46 + // Creator 47 + HStack { 48 + AvatarView(url: gallery.creator.avatar, size: 32) 49 + Text(gallery.creator.displayName ?? gallery.creator.handle) 50 + .font(.subheadline.bold()) 51 + } 52 + 53 + // Stats & Actions with glass pill 54 + HStack(spacing: 16) { 55 + Button { 56 + Task { await viewModel.toggleFavorite(auth: auth.authContext()) } 57 + } label: { 58 + Label( 59 + "\(gallery.favCount ?? 0)", 60 + systemImage: gallery.viewer?.fav != nil ? "heart.fill" : "heart" 61 + ) 62 + } 63 + .foregroundStyle(gallery.viewer?.fav != nil ? .red : .primary) 64 + 65 + Label("\(gallery.commentCount ?? 0)", systemImage: "bubble.right") 66 + 67 + if let cameras = gallery.cameras, !cameras.isEmpty { 68 + Label(cameras.first ?? "", systemImage: "camera") 69 + .font(.caption) 70 + .foregroundStyle(.secondary) 71 + } 72 + } 73 + .font(.subheadline) 74 + .padding(.horizontal, 16) 75 + .padding(.vertical, 10) 76 + .liquidGlass() 77 + } 78 + .padding(.horizontal) 79 + 80 + // Comments 81 + if !viewModel.comments.isEmpty { 82 + VStack(alignment: .leading, spacing: 12) { 83 + Text("Comments") 84 + .font(.headline) 85 + .padding(.horizontal) 86 + 87 + ForEach(viewModel.comments) { comment in 88 + CommentRow(comment: comment) 89 + } 90 + } 91 + } 92 + } 93 + } else if viewModel.isLoading { 94 + ProgressView() 95 + .frame(maxWidth: .infinity, maxHeight: .infinity) 96 + .padding(.top, 100) 97 + } 98 + } 99 + .navigationBarTitleDisplayMode(.inline) 100 + .task { 101 + await viewModel.load(uri: galleryUri, auth: auth.authContext()) 102 + } 103 + } 104 + } 105 + 106 + struct CommentRow: View { 107 + let comment: GrainComment 108 + 109 + var body: some View { 110 + HStack(alignment: .top, spacing: 8) { 111 + AvatarView(url: comment.author.avatar, size: 28) 112 + 113 + VStack(alignment: .leading, spacing: 2) { 114 + Text(comment.author.displayName ?? comment.author.handle) 115 + .font(.caption.bold()) 116 + Text(comment.text) 117 + .font(.caption) 118 + } 119 + } 120 + .padding(.horizontal) 121 + } 122 + }
+74
Grain/Views/LoginView.swift
··· 1 + import SwiftUI 2 + 3 + struct LoginView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @State private var handle = "" 6 + @State private var isLoading = false 7 + @State private var errorMessage: String? 8 + 9 + var body: some View { 10 + NavigationStack { 11 + VStack(spacing: 32) { 12 + Spacer() 13 + 14 + VStack(spacing: 8) { 15 + Text("Grain") 16 + .font(.largeTitle.bold()) 17 + Text("Photo sharing on AT Protocol") 18 + .font(.subheadline) 19 + .foregroundStyle(.secondary) 20 + } 21 + 22 + // Login card with glass effect 23 + VStack(spacing: 16) { 24 + TextField("Enter your handle", text: $handle) 25 + .textFieldStyle(.roundedBorder) 26 + .textContentType(.username) 27 + .autocorrectionDisabled() 28 + .textInputAutocapitalization(.never) 29 + 30 + Button { 31 + Task { await login() } 32 + } label: { 33 + if isLoading { 34 + ProgressView() 35 + .frame(maxWidth: .infinity) 36 + } else { 37 + Text("Sign In") 38 + .frame(maxWidth: .infinity) 39 + } 40 + } 41 + .buttonStyle(.borderedProminent) 42 + .disabled(handle.isEmpty || isLoading) 43 + } 44 + .padding(24) 45 + .liquidGlass() 46 + .padding(.horizontal) 47 + 48 + if let errorMessage { 49 + ScrollView { 50 + Text(errorMessage) 51 + .font(.caption.monospaced()) 52 + .foregroundStyle(.red) 53 + .textSelection(.enabled) 54 + .padding(.horizontal) 55 + } 56 + .frame(maxHeight: 200) 57 + } 58 + 59 + Spacer() 60 + } 61 + } 62 + } 63 + 64 + private func login() async { 65 + isLoading = true 66 + errorMessage = nil 67 + do { 68 + try await auth.login(handle: handle) 69 + } catch { 70 + errorMessage = String(describing: error) 71 + } 72 + isLoading = false 73 + } 74 + }
+84
Grain/Views/MainTabView.swift
··· 1 + import SwiftUI 2 + 3 + struct MainTabView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @State private var selectedTab = 0 6 + @State private var client = XRPCClient(baseURL: AuthManager.serverURL) 7 + @State private var showCreate = false 8 + @State private var avatarTabImage: UIImage? 9 + 10 + var body: some View { 11 + TabView(selection: $selectedTab) { 12 + TabSection { 13 + Tab("Feed", systemImage: "photo.on.rectangle", value: 0) { 14 + FeedView(client: client) 15 + } 16 + 17 + Tab("Search", systemImage: "magnifyingglass", value: 1) { 18 + SearchView(client: client) 19 + } 20 + 21 + Tab("Notifications", systemImage: "bell", value: 2) { 22 + NotificationsView(client: client) 23 + } 24 + 25 + Tab(value: 3) { 26 + if let did = auth.userDID { 27 + ProfileView(client: client, did: did) 28 + } 29 + } label: { 30 + if let img = avatarTabImage { 31 + Label { 32 + Text("Profile") 33 + } icon: { 34 + Image(uiImage: img) 35 + .renderingMode(.original) 36 + } 37 + } else { 38 + Label("Profile", systemImage: "person") 39 + } 40 + } 41 + } 42 + 43 + Tab(value: 99, role: .search) { 44 + Color.clear 45 + } label: { 46 + Label("Create", systemImage: "plus") 47 + } 48 + } 49 + .tabBarMinimizeBehavior(.onScrollDown) 50 + .task { 51 + await auth.fetchAvatarIfNeeded() 52 + if let uiImage = auth.avatarImage { 53 + avatarTabImage = circularAvatar(uiImage, size: 26) 54 + } 55 + } 56 + .onChange(of: auth.avatarImage) { 57 + if let uiImage = auth.avatarImage { 58 + avatarTabImage = circularAvatar(uiImage, size: 26) 59 + } 60 + } 61 + .onChange(of: selectedTab) { oldValue, newValue in 62 + if newValue == 99 { 63 + selectedTab = oldValue 64 + showCreate = true 65 + } 66 + } 67 + .sheet(isPresented: $showCreate) { 68 + NavigationStack { 69 + CreateGalleryView(client: client) 70 + } 71 + } 72 + } 73 + 74 + private func circularAvatar(_ image: UIImage, size: CGFloat) -> UIImage { 75 + let rect = CGRect(origin: .zero, size: CGSize(width: size, height: size)) 76 + let renderer = UIGraphicsImageRenderer(size: rect.size) 77 + let circled = renderer.image { _ in 78 + UIBezierPath(ovalIn: rect).addClip() 79 + image.draw(in: rect) 80 + } 81 + return circled.withRenderingMode(.alwaysOriginal) 82 + } 83 + } 84 +
+103
Grain/Views/Notifications/NotificationsView.swift
··· 1 + import SwiftUI 2 + import NukeUI 3 + 4 + struct NotificationsView: View { 5 + @Environment(AuthManager.self) private var auth 6 + @State private var viewModel: NotificationsViewModel 7 + 8 + init(client: XRPCClient) { 9 + _viewModel = State(initialValue: NotificationsViewModel(client: client)) 10 + } 11 + 12 + var body: some View { 13 + NavigationStack { 14 + List { 15 + ForEach(viewModel.notifications) { notification in 16 + NotificationRow(notification: notification) 17 + .onAppear { 18 + if notification.id == viewModel.notifications.last?.id { 19 + Task { await viewModel.loadMore(auth: auth.authContext()) } 20 + } 21 + } 22 + } 23 + 24 + if viewModel.isLoading { 25 + HStack { 26 + Spacer() 27 + ProgressView() 28 + Spacer() 29 + } 30 + } 31 + } 32 + .listStyle(.plain) 33 + .refreshable { 34 + await viewModel.loadInitial(auth: auth.authContext()) 35 + } 36 + .navigationTitle("Notifications") 37 + .task { 38 + if viewModel.notifications.isEmpty { 39 + await viewModel.loadInitial(auth: auth.authContext()) 40 + } 41 + } 42 + } 43 + } 44 + } 45 + 46 + struct NotificationRow: View { 47 + let notification: GrainNotification 48 + 49 + var body: some View { 50 + HStack(alignment: .top, spacing: 12) { 51 + AvatarView(url: notification.author.avatar, size: 36) 52 + 53 + VStack(alignment: .leading, spacing: 4) { 54 + HStack(spacing: 4) { 55 + Text(notification.author.displayName ?? notification.author.handle) 56 + .font(.subheadline.bold()) 57 + Text(reasonText) 58 + .font(.subheadline) 59 + .foregroundStyle(.secondary) 60 + } 61 + 62 + if let galleryTitle = notification.galleryTitle { 63 + Text(galleryTitle) 64 + .font(.caption) 65 + .foregroundStyle(.secondary) 66 + .lineLimit(1) 67 + } 68 + 69 + if let commentText = notification.commentText { 70 + Text(commentText) 71 + .font(.caption) 72 + .lineLimit(2) 73 + } 74 + } 75 + 76 + Spacer() 77 + 78 + if let thumb = notification.galleryThumb, let url = URL(string: thumb) { 79 + LazyImage(url: url) { state in 80 + if let image = state.image { 81 + image.resizable() 82 + } else { 83 + Rectangle().fill(.quaternary) 84 + } 85 + } 86 + .frame(width: 44, height: 44) 87 + .clipShape(RoundedRectangle(cornerRadius: 6)) 88 + } 89 + } 90 + } 91 + 92 + private var reasonText: String { 93 + switch notification.reasonType { 94 + case .galleryFavorite: "favorited your gallery" 95 + case .galleryComment: "commented on your gallery" 96 + case .galleryCommentMention: "mentioned you in a comment" 97 + case .galleryMention: "mentioned you" 98 + case .reply: "replied to your comment" 99 + case .follow: "followed you" 100 + case .unknown: "" 101 + } 102 + } 103 + }
+133
Grain/Views/Profile/ProfileView.swift
··· 1 + import SwiftUI 2 + import NukeUI 3 + 4 + struct ProfileView: View { 5 + @Environment(AuthManager.self) private var auth 6 + @State private var viewModel: ProfileDetailViewModel 7 + 8 + let did: String 9 + 10 + init(client: XRPCClient, did: String) { 11 + _viewModel = State(initialValue: ProfileDetailViewModel(client: client)) 12 + self.did = did 13 + } 14 + 15 + var body: some View { 16 + NavigationStack { 17 + ScrollView { 18 + if let profile = viewModel.profile { 19 + VStack(spacing: 16) { 20 + // Avatar + name with glass header 21 + VStack(spacing: 8) { 22 + AvatarView(url: profile.avatar, size: 80) 23 + .liquidGlassCircle() 24 + 25 + Text(profile.displayName ?? profile.handle) 26 + .font(.title2.bold()) 27 + Text("@\(profile.handle)") 28 + .font(.subheadline) 29 + .foregroundStyle(.secondary) 30 + } 31 + .padding(.vertical) 32 + 33 + // Stats with glass pills 34 + HStack(spacing: 24) { 35 + StatView(count: profile.galleryCount ?? 0, label: "Galleries") 36 + StatView(count: profile.followersCount ?? 0, label: "Followers") 37 + StatView(count: profile.followsCount ?? 0, label: "Following") 38 + } 39 + .padding(.horizontal, 20) 40 + .padding(.vertical, 12) 41 + .liquidGlass() 42 + 43 + // Follow button 44 + if did != auth.userDID { 45 + Button { 46 + Task { await viewModel.toggleFollow(auth: auth.authContext()) } 47 + } label: { 48 + Text(profile.viewer?.following != nil ? "Following" : "Follow") 49 + .frame(maxWidth: .infinity) 50 + } 51 + .buttonStyle(.borderedProminent) 52 + .tint(profile.viewer?.following != nil ? .secondary : .accentColor) 53 + .padding(.horizontal) 54 + } 55 + 56 + if let description = profile.description, !description.isEmpty { 57 + Text(description) 58 + .font(.body) 59 + .padding(.horizontal) 60 + } 61 + 62 + // Gallery grid 63 + LazyVGrid(columns: [ 64 + GridItem(.flexible(), spacing: 2), 65 + GridItem(.flexible(), spacing: 2), 66 + GridItem(.flexible(), spacing: 2) 67 + ], spacing: 2) { 68 + ForEach(viewModel.galleries) { gallery in 69 + NavigationLink(value: gallery.uri) { 70 + if let photo = gallery.items?.first { 71 + LazyImage(url: URL(string: photo.thumb)) { state in 72 + if let image = state.image { 73 + image 74 + .resizable() 75 + .aspectRatio(1, contentMode: .fill) 76 + } else { 77 + Rectangle().fill(.quaternary) 78 + } 79 + } 80 + .aspectRatio(1, contentMode: .fill) 81 + .clipped() 82 + } 83 + } 84 + .onAppear { 85 + if gallery.id == viewModel.galleries.last?.id { 86 + Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } 87 + } 88 + } 89 + } 90 + } 91 + } 92 + } else if viewModel.isLoading { 93 + ProgressView() 94 + .padding(.top, 100) 95 + } 96 + } 97 + .navigationTitle("") 98 + .navigationBarTitleDisplayMode(.inline) 99 + .toolbar { 100 + if did == auth.userDID { 101 + ToolbarItem(placement: .topBarTrailing) { 102 + NavigationLink { 103 + SettingsView() 104 + } label: { 105 + Image(systemName: "gearshape") 106 + } 107 + } 108 + } 109 + } 110 + .navigationDestination(for: String.self) { uri in 111 + GalleryDetailView(client: XRPCClient(baseURL: AuthManager.serverURL), galleryUri: uri) 112 + } 113 + .task { 114 + await viewModel.load(did: did, auth: auth.authContext()) 115 + } 116 + } 117 + } 118 + } 119 + 120 + struct StatView: View { 121 + let count: Int 122 + let label: String 123 + 124 + var body: some View { 125 + VStack(spacing: 2) { 126 + Text("\(count)") 127 + .font(.headline) 128 + Text(label) 129 + .font(.caption) 130 + .foregroundStyle(.secondary) 131 + } 132 + } 133 + }
+136
Grain/Views/Search/SearchView.swift
··· 1 + import SwiftUI 2 + import NukeUI 3 + 4 + struct SearchView: View { 5 + @Environment(AuthManager.self) private var auth 6 + @State private var viewModel: SearchViewModel 7 + @State private var searchNavigationUri: String? 8 + let client: XRPCClient 9 + 10 + init(client: XRPCClient) { 11 + self.client = client 12 + _viewModel = State(initialValue: SearchViewModel(client: client)) 13 + } 14 + 15 + var body: some View { 16 + NavigationStack { 17 + VStack(spacing: 0) { 18 + if viewModel.searchText.isEmpty { 19 + // Discovery view 20 + ScrollView { 21 + VStack(alignment: .leading, spacing: 24) { 22 + if !viewModel.locations.isEmpty { 23 + VStack(alignment: .leading, spacing: 8) { 24 + Text("Locations") 25 + .font(.headline) 26 + .padding(.horizontal) 27 + 28 + ScrollView(.horizontal, showsIndicators: false) { 29 + HStack(spacing: 12) { 30 + ForEach(viewModel.locations) { location in 31 + VStack { 32 + Text(location.name) 33 + .font(.subheadline.bold()) 34 + Text("\(location.galleryCount) galleries") 35 + .font(.caption) 36 + .foregroundStyle(.secondary) 37 + } 38 + .padding(.horizontal, 16) 39 + .padding(.vertical, 8) 40 + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) 41 + } 42 + } 43 + .padding(.horizontal) 44 + } 45 + } 46 + } 47 + 48 + if !viewModel.cameras.isEmpty { 49 + VStack(alignment: .leading, spacing: 8) { 50 + Text("Cameras") 51 + .font(.headline) 52 + .padding(.horizontal) 53 + 54 + ScrollView(.horizontal, showsIndicators: false) { 55 + HStack(spacing: 12) { 56 + ForEach(viewModel.cameras) { camera in 57 + VStack { 58 + Text(camera.camera) 59 + .font(.subheadline.bold()) 60 + Text("\(camera.photoCount) photos") 61 + .font(.caption) 62 + .foregroundStyle(.secondary) 63 + } 64 + .padding(.horizontal, 16) 65 + .padding(.vertical, 8) 66 + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) 67 + } 68 + } 69 + .padding(.horizontal) 70 + } 71 + } 72 + } 73 + } 74 + .padding(.vertical) 75 + } 76 + } else { 77 + // Search results 78 + Picker("Search", selection: $viewModel.selectedTab) { 79 + ForEach(SearchViewModel.SearchTab.allCases, id: \.self) { tab in 80 + Text(tab.rawValue).tag(tab) 81 + } 82 + } 83 + .pickerStyle(.segmented) 84 + .padding(.horizontal) 85 + 86 + ScrollView { 87 + LazyVStack(spacing: 12) { 88 + switch viewModel.selectedTab { 89 + case .galleries: 90 + ForEach($viewModel.galleryResults) { $gallery in 91 + GalleryCardView(gallery: $gallery, client: client) { 92 + searchNavigationUri = gallery.uri 93 + } 94 + } 95 + case .profiles: 96 + ForEach(viewModel.profileResults) { profile in 97 + HStack { 98 + AvatarView(url: profile.avatar, size: 40) 99 + VStack(alignment: .leading) { 100 + Text(profile.displayName ?? profile.handle ?? "") 101 + .font(.subheadline.bold()) 102 + if let handle = profile.handle { 103 + Text("@\(handle)") 104 + .font(.caption) 105 + .foregroundStyle(.secondary) 106 + } 107 + } 108 + Spacer() 109 + } 110 + .padding(.horizontal) 111 + } 112 + } 113 + } 114 + .padding(.top) 115 + } 116 + } 117 + } 118 + .navigationTitle("Search") 119 + .searchable(text: $viewModel.searchText, prompt: "Search galleries & profiles") 120 + .onSubmit(of: .search) { 121 + Task { await viewModel.search(auth: auth.authContext()) } 122 + } 123 + .onChange(of: viewModel.selectedTab) { 124 + if !viewModel.searchText.isEmpty { 125 + Task { await viewModel.search(auth: auth.authContext()) } 126 + } 127 + } 128 + .task { 129 + await viewModel.loadDiscovery(auth: auth.authContext()) 130 + } 131 + .navigationDestination(item: $searchNavigationUri) { uri in 132 + GalleryDetailView(client: client, galleryUri: uri) 133 + } 134 + } 135 + } 136 + }
+35
Grain/Views/Settings/SettingsView.swift
··· 1 + import SwiftUI 2 + 3 + struct SettingsView: View { 4 + @Environment(AuthManager.self) private var auth 5 + @Environment(\.dismiss) private var dismiss 6 + 7 + var body: some View { 8 + List { 9 + Section("Account") { 10 + if let handle = auth.userHandle { 11 + LabeledContent("Handle", value: "@\(handle)") 12 + } 13 + if let did = auth.userDID { 14 + LabeledContent("DID", value: did) 15 + .font(.caption) 16 + } 17 + } 18 + 19 + Section { 20 + NavigationLink("Edit Profile") { 21 + // TODO: EditProfileView 22 + Text("Edit Profile") 23 + } 24 + } 25 + 26 + Section { 27 + Button("Sign Out", role: .destructive) { 28 + auth.logout() 29 + dismiss() 30 + } 31 + } 32 + } 33 + .navigationTitle("Settings") 34 + } 35 + }
+104
Grain/Views/Stories/StoryViewer.swift
··· 1 + import SwiftUI 2 + import NukeUI 3 + 4 + struct StoryViewer: View { 5 + let stories: [GrainStory] 6 + @State private var currentIndex: Int 7 + @Environment(\.dismiss) private var dismiss 8 + 9 + init(stories: [GrainStory], startIndex: Int = 0) { 10 + self.stories = stories 11 + _currentIndex = State(initialValue: startIndex) 12 + } 13 + 14 + var body: some View { 15 + ZStack { 16 + Color.black.ignoresSafeArea() 17 + 18 + if currentIndex < stories.count { 19 + let story = stories[currentIndex] 20 + 21 + LazyImage(url: URL(string: story.fullsize)) { state in 22 + if let image = state.image { 23 + image 24 + .resizable() 25 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 26 + } else if state.isLoading { 27 + ProgressView() 28 + .tint(.white) 29 + } 30 + } 31 + 32 + // Progress indicators + creator info 33 + VStack { 34 + HStack(spacing: 4) { 35 + ForEach(0..<stories.count, id: \.self) { index in 36 + Capsule() 37 + .fill(index <= currentIndex ? Color.white : Color.white.opacity(0.3)) 38 + .frame(height: 2) 39 + } 40 + } 41 + .padding(.horizontal) 42 + .padding(.top, 8) 43 + 44 + // Creator info with glass effect 45 + HStack { 46 + AvatarView(url: story.creator.avatar, size: 32) 47 + Text(story.creator.displayName ?? story.creator.handle) 48 + .font(.subheadline.bold()) 49 + .foregroundStyle(.white) 50 + Spacer() 51 + Button { dismiss() } label: { 52 + Image(systemName: "xmark") 53 + .foregroundStyle(.white) 54 + } 55 + } 56 + .padding(.horizontal, 16) 57 + .padding(.vertical, 8) 58 + .liquidGlass() 59 + .padding(.horizontal) 60 + 61 + Spacer() 62 + 63 + // Location pill at bottom 64 + if let address = story.address { 65 + HStack { 66 + Image(systemName: "location.fill") 67 + Text(address.locality ?? address.name ?? address.country) 68 + } 69 + .font(.caption) 70 + .foregroundStyle(.white) 71 + .padding(.horizontal, 12) 72 + .padding(.vertical, 6) 73 + .liquidGlass() 74 + .padding(.bottom, 32) 75 + } 76 + } 77 + } 78 + } 79 + .contentShape(Rectangle()) 80 + .gesture( 81 + DragGesture(minimumDistance: 0) 82 + .onEnded { value in 83 + if value.translation.width < -50 { 84 + if currentIndex < stories.count - 1 { 85 + currentIndex += 1 86 + } else { 87 + dismiss() 88 + } 89 + } else if value.translation.width > 50 { 90 + if currentIndex > 0 { 91 + currentIndex -= 1 92 + } 93 + } else { 94 + if value.startLocation.x > UIScreen.main.bounds.width / 2 { 95 + if currentIndex < stories.count - 1 { currentIndex += 1 } else { dismiss() } 96 + } else { 97 + if currentIndex > 0 { currentIndex -= 1 } 98 + } 99 + } 100 + } 101 + ) 102 + .statusBarHidden() 103 + } 104 + }
+31
GrainTests/GrainTests.swift
··· 1 + import XCTest 2 + @testable import Grain 3 + 4 + final class GrainTests: XCTestCase { 5 + func testAspectRatio() { 6 + let ratio = AspectRatio(width: 16, height: 9) 7 + XCTAssertEqual(ratio.ratio, 16.0 / 9.0, accuracy: 0.001) 8 + } 9 + 10 + func testBase64URLEncoding() { 11 + let data = Data([0xFF, 0xFE, 0xFD]) 12 + let encoded = data.base64URLEncoded() 13 + XCTAssertFalse(encoded.contains("+")) 14 + XCTAssertFalse(encoded.contains("/")) 15 + XCTAssertFalse(encoded.contains("=")) 16 + } 17 + 18 + func testTokenStorageClear() { 19 + TokenStorage.clear() 20 + XCTAssertNil(TokenStorage.accessToken) 21 + XCTAssertNil(TokenStorage.refreshToken) 22 + XCTAssertNil(TokenStorage.userDID) 23 + XCTAssertTrue(TokenStorage.isExpired) 24 + } 25 + 26 + func testNotificationReasonParsing() { 27 + XCTAssertEqual(NotificationReason(rawValue: "gallery-favorite"), .galleryFavorite) 28 + XCTAssertEqual(NotificationReason(rawValue: "follow"), .follow) 29 + XCTAssertNil(NotificationReason(rawValue: "invalid")) 30 + } 31 + }
+171
docs/plans/2026-03-29-grain-native-ios-design.md
··· 1 + # Grain Native iOS App Design 2 + 3 + A full-featured Swift iOS app for Grain, targeting feature parity with the SvelteKit web app. 4 + 5 + ## Constraints 6 + 7 + - **iOS 17+**, SwiftUI + MVVM with `@Observable` 8 + - **SPM** for dependencies 9 + - **Bundle ID:** `social.grain.app` 10 + - **Target directory:** `~/code/grain-native` 11 + 12 + ## Dependencies 13 + 14 + - **Nuke** -- image loading, caching, prefetching 15 + - **JOSESwift** or **Swift-JWT** -- ES256 signing for DPoP proofs 16 + - **KeychainAccess** -- Keychain wrapper for key/token storage 17 + 18 + ## Project Structure 19 + 20 + ``` 21 + Grain/ 22 + Grain.xcodeproj 23 + Grain/ 24 + GrainApp.swift 25 + Info.plist 26 + Assets.xcassets 27 + Models/ 28 + Records/ # Codable structs from lexicons 29 + Views/ # API response view types 30 + Common/ # AspectRatio, Location, Facet, BlobRef 31 + API/ 32 + XRPCClient.swift 33 + DPoP.swift 34 + AuthManager.swift 35 + TokenStorage.swift 36 + Endpoints/ 37 + ViewModels/ 38 + Views/ 39 + Feed/ 40 + Gallery/ 41 + Profile/ 42 + Stories/ 43 + Search/ 44 + Notifications/ 45 + Create/ 46 + Settings/ 47 + Utilities/ 48 + GrainTests/ 49 + ``` 50 + 51 + ## Authentication 52 + 53 + OAuth with DPoP against the hatk server. 54 + 55 + **Client registration:** Custom URL scheme (`grain://oauth/callback`). Client metadata served or configured on the hatk server to accept this redirect URI. 56 + 57 + **Flow:** 58 + 59 + 1. Generate ES256 P-256 key pair, store private key in Keychain. 60 + 2. `POST /oauth/par` with DPoP proof, PKCE challenge (`S256`), `client_id`, redirect URI `grain://oauth/callback`. 61 + 3. Open `ASWebAuthenticationSession` to `/oauth/authorize?request_uri=<uri>&client_id=<id>`. 62 + 4. Capture callback code, exchange at `POST /oauth/token` with DPoP proof + code verifier. 63 + 5. Store access token, refresh token, expiry in Keychain. 64 + 6. All requests include `Authorization: DPoP <token>` header and a `DPoP` proof header. 65 + 7. On 401 or token expiry, refresh with rotation. Handle `use_dpop_nonce` retry. 66 + 67 + **DPoP proof requirements:** 68 + 69 + - `typ: dpop+jwt`, `alg: ES256` 70 + - Public key (`jwk`) in header with minimal `kty`, `crv`, `x`, `y` 71 + - Claims: `jti` (UUID), `htm` (method), `htu` (URL without query), `iat` (timestamp) 72 + - For authenticated requests: `ath` (base64url SHA256 of access token) 73 + - For nonce retries: `nonce` claim from `DPoP-Nonce` response header 74 + 75 + ## API Layer 76 + 77 + Hand-written `XRPCClient` with generated `Codable` model types. 78 + 79 + ```swift 80 + let gallery = try await client.query( 81 + "social.grain.unspecced.getGallery", 82 + params: ["uri": galleryUri], 83 + as: GetGalleryResponse.self 84 + ) 85 + ``` 86 + 87 + **Queries (GET):** 88 + 89 + - `social.grain.unspecced.getActorProfile` -- profile with stats 90 + - `social.grain.unspecced.getFollowers` / `getFollowing` / `getKnownFollowers` 91 + - `social.grain.unspecced.getSuggestedFollows` 92 + - `social.grain.unspecced.getGallery` -- single gallery with photos 93 + - `social.grain.unspecced.getGalleryThread` -- paginated comments 94 + - `social.grain.unspecced.getStories` / `getStoryArchive` / `getStory` 95 + - `social.grain.unspecced.getStoryAuthors` 96 + - `social.grain.unspecced.searchGalleries` / `searchProfiles` 97 + - `social.grain.unspecced.getLocations` / `getCameras` 98 + - `social.grain.unspecced.getNotifications` 99 + - `dev.hatk.getFeed` -- feeds with cursor pagination (recent, following, actor, camera, location, hashtag) 100 + - `dev.hatk.getRecord` / `getRecords` 101 + - `dev.hatk.getPreferences` 102 + 103 + **Procedures (POST):** 104 + 105 + - `dev.hatk.createRecord` / `putRecord` / `deleteRecord` 106 + - `dev.hatk.uploadBlob` 107 + - `dev.hatk.putPreference` 108 + - `social.grain.unspecced.deleteGallery` 109 + 110 + ## Data Model 111 + 112 + Generated `Codable` structs from lexicon JSON files. 113 + 114 + **Records:** 115 + 116 + - `Gallery` -- title, description, location, EXIF camera data, timestamps 117 + - `Photo` -- blob, aspect ratio, alt text, EXIF metadata 118 + - `PhotoExif` -- ISO, aperture, focal length, camera make/model (integers scaled x1,000,000) 119 + - `GalleryItem` -- links photo to gallery with position 120 + - `Comment` -- text, facets, reply threading 121 + - `Story` -- ephemeral 24-hour media with location and aspect ratio 122 + - `Favorite` -- points to gallery URI 123 + - `Follow` -- follow relationship 124 + - `ActorProfile` -- displayName, description, avatar blob 125 + 126 + **View types:** 127 + 128 + - `GalleryView` -- gallery with photos, creator, stats, cameras, location, viewer state 129 + - `PhotoView` -- photo with thumbnail/fullsize URLs, alt text, aspect ratio, EXIF 130 + - `ProfileView` / `ProfileViewDetailed` -- profile with optional stats and viewer relationship 131 + - `StoryView` -- story with creator, media URLs, timestamps 132 + - `NotificationItem` -- notification with reason, author, gallery/comment context 133 + 134 + **Common types:** 135 + 136 + - `AspectRatio` -- width/height 137 + - `Location` -- H3 cell + address (country, region, locality, street) 138 + - `Facet` -- rich text mentions, links, hashtags 139 + - `BlobRef` -- blob reference for uploads 140 + 141 + ## Navigation 142 + 143 + `NavigationStack` with `Router` observable managing path state. 144 + 145 + **Tab bar (5 tabs):** Feed | Search | Create | Notifications | Profile 146 + 147 + ## Screens 148 + 149 + | Screen | ViewModel | Notes | 150 + |--------|-----------|-------| 151 + | Home feed | `FeedViewModel` | Paginated galleries, pull-to-refresh, cursor pagination | 152 + | Following feed | `FeedViewModel` | Reused with different feed param | 153 + | Gallery detail | `GalleryViewModel` | Photos, comment thread, favorite toggle | 154 + | Profile | `ProfileViewModel` | User info, gallery grid, follow/unfollow, stories | 155 + | Story viewer | `StoryViewModel` | Timed progression, swipe between authors | 156 + | Create gallery | `CreateGalleryViewModel` | Photo picker, EXIF extraction, upload queue, title/description/location | 157 + | Search | `SearchViewModel` | Text search galleries + profiles, camera/location/hashtag discovery | 158 + | Notifications | `NotificationsViewModel` | Paginated list, 6 reason types | 159 + | Edit profile | `SettingsViewModel` | Display name, description, avatar upload | 160 + | Comments | `CommentsViewModel` | Threaded replies, post comment | 161 + | Followers/Following | `FollowListViewModel` | Paginated user lists | 162 + | Camera feed | `FeedViewModel` | Filtered by camera name | 163 + | Location feed | `FeedViewModel` | Filtered by H3 cell | 164 + | Hashtag feed | `FeedViewModel` | Filtered by tag | 165 + 166 + ## Shared Patterns 167 + 168 + - Cursor-based pagination via `loadMore()` 169 + - Pull-to-refresh on all list views 170 + - Nuke `LazyImage` for all photo rendering with prefetching on scroll 171 + - Optimistic UI updates for favorites, follows, comments
+64
project.yml
··· 1 + name: Grain 2 + options: 3 + bundleIdPrefix: social.grain 4 + deploymentTarget: 5 + iOS: "26.0" 6 + xcodeVersion: "26.0" 7 + generateEmptyDirectories: true 8 + 9 + settings: 10 + base: 11 + SWIFT_VERSION: "6.0" 12 + DEVELOPMENT_TEAM: YN68LN9T7Z 13 + 14 + packages: 15 + Nuke: 16 + url: https://github.com/kean/Nuke 17 + from: "12.0.0" 18 + KeychainAccess: 19 + url: https://github.com/kishikawakatsumi/KeychainAccess 20 + from: "4.2.2" 21 + 22 + targets: 23 + Grain: 24 + type: application 25 + platform: iOS 26 + sources: 27 + - path: Grain 28 + settings: 29 + base: 30 + INFOPLIST_FILE: Grain/Info.plist 31 + PRODUCT_BUNDLE_IDENTIFIER: social.grain.grain 32 + MARKETING_VERSION: "1.0.0" 33 + CURRENT_PROJECT_VERSION: "2" 34 + dependencies: 35 + - package: Nuke 36 + product: Nuke 37 + - package: Nuke 38 + product: NukeUI 39 + - package: KeychainAccess 40 + info: 41 + path: Grain/Info.plist 42 + properties: 43 + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" 44 + CFBundleShortVersionString: "$(MARKETING_VERSION)" 45 + CFBundleURLTypes: 46 + - CFBundleURLSchemes: 47 + - grain 48 + CFBundleIconName: AppIcon 49 + UISupportedInterfaceOrientations: 50 + - UIInterfaceOrientationPortrait 51 + - UIInterfaceOrientationPortraitUpsideDown 52 + - UIInterfaceOrientationLandscapeLeft 53 + - UIInterfaceOrientationLandscapeRight 54 + NSAppTransportSecurity: 55 + NSAllowsLocalNetworking: true 56 + UILaunchScreen: {} 57 + 58 + GrainTests: 59 + type: bundle.unit-test 60 + platform: iOS 61 + sources: 62 + - path: GrainTests 63 + dependencies: 64 + - target: Grain