~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
171 lines · 6.1 KB Raw History

Grain Native iOS App Design

A full-featured Swift iOS app for Grain, targeting feature parity with the SvelteKit web app.

Constraints

  • iOS 17+, SwiftUI + MVVM with @Observable
  • SPM for dependencies
  • Bundle ID: social.grain.app
  • Target directory: ~/code/grain-native

Dependencies

  • Nuke -- image loading, caching, prefetching
  • JOSESwift or Swift-JWT -- ES256 signing for DPoP proofs
  • KeychainAccess -- Keychain wrapper for key/token storage

Project Structure

Grain/
  Grain.xcodeproj
  Grain/
    GrainApp.swift
    Info.plist
    Assets.xcassets
    Models/
      Records/          # Codable structs from lexicons
      Views/            # API response view types
      Common/           # AspectRatio, Location, Facet, BlobRef
    API/
      XRPCClient.swift
      DPoP.swift
      AuthManager.swift
      TokenStorage.swift
      Endpoints/
    ViewModels/
    Views/
      Feed/
      Gallery/
      Profile/
      Stories/
      Search/
      Notifications/
      Create/
      Settings/
    Utilities/
  GrainTests/

Authentication

OAuth with DPoP against the hatk server.

Client registration: Custom URL scheme (grain://oauth/callback). Client metadata served or configured on the hatk server to accept this redirect URI.

Flow:

  1. Generate ES256 P-256 key pair, store private key in Keychain.
  2. POST /oauth/par with DPoP proof, PKCE challenge (S256), client_id, redirect URI grain://oauth/callback.
  3. Open ASWebAuthenticationSession to /oauth/authorize?request_uri=<uri>&client_id=<id>.
  4. Capture callback code, exchange at POST /oauth/token with DPoP proof + code verifier.
  5. Store access token, refresh token, expiry in Keychain.
  6. All requests include Authorization: DPoP <token> header and a DPoP proof header.
  7. On 401 or token expiry, refresh with rotation. Handle use_dpop_nonce retry.

DPoP proof requirements:

  • typ: dpop+jwt, alg: ES256
  • Public key (jwk) in header with minimal kty, crv, x, y
  • Claims: jti (UUID), htm (method), htu (URL without query), iat (timestamp)
  • For authenticated requests: ath (base64url SHA256 of access token)
  • For nonce retries: nonce claim from DPoP-Nonce response header

API Layer

Hand-written XRPCClient with generated Codable model types.

let gallery = try await client.query(
    "social.grain.unspecced.getGallery",
    params: ["uri": galleryUri],
    as: GetGalleryResponse.self
)

Queries (GET):

  • social.grain.unspecced.getActorProfile -- profile with stats
  • social.grain.unspecced.getFollowers / getFollowing / getKnownFollowers
  • social.grain.unspecced.getSuggestedFollows
  • social.grain.unspecced.getGallery -- single gallery with photos
  • social.grain.unspecced.getGalleryThread -- paginated comments
  • social.grain.unspecced.getStories / getStoryArchive / getStory
  • social.grain.unspecced.getStoryAuthors
  • social.grain.unspecced.searchGalleries / searchProfiles
  • social.grain.unspecced.getLocations / getCameras
  • social.grain.unspecced.getNotifications
  • dev.hatk.getFeed -- feeds with cursor pagination (recent, following, actor, camera, location, hashtag)
  • dev.hatk.getRecord / getRecords
  • dev.hatk.getPreferences

Procedures (POST):

  • dev.hatk.createRecord / putRecord / deleteRecord
  • dev.hatk.uploadBlob
  • dev.hatk.putPreference
  • social.grain.unspecced.deleteGallery

Data Model

Generated Codable structs from lexicon JSON files.

Records:

  • Gallery -- title, description, location, EXIF camera data, timestamps
  • Photo -- blob, aspect ratio, alt text, EXIF metadata
  • PhotoExif -- ISO, aperture, focal length, camera make/model (integers scaled x1,000,000)
  • GalleryItem -- links photo to gallery with position
  • Comment -- text, facets, reply threading
  • Story -- ephemeral 24-hour media with location and aspect ratio
  • Favorite -- points to gallery URI
  • Follow -- follow relationship
  • ActorProfile -- displayName, description, avatar blob

View types:

  • GalleryView -- gallery with photos, creator, stats, cameras, location, viewer state
  • PhotoView -- photo with thumbnail/fullsize URLs, alt text, aspect ratio, EXIF
  • ProfileView / ProfileViewDetailed -- profile with optional stats and viewer relationship
  • StoryView -- story with creator, media URLs, timestamps
  • NotificationItem -- notification with reason, author, gallery/comment context

Common types:

  • AspectRatio -- width/height
  • Location -- H3 cell + address (country, region, locality, street)
  • Facet -- rich text mentions, links, hashtags
  • BlobRef -- blob reference for uploads

Navigation

NavigationStack with Router observable managing path state.

Tab bar (5 tabs): Feed | Search | Create | Notifications | Profile

Screens

ScreenViewModelNotes
Home feedFeedViewModelPaginated galleries, pull-to-refresh, cursor pagination
Following feedFeedViewModelReused with different feed param
Gallery detailGalleryViewModelPhotos, comment thread, favorite toggle
ProfileProfileViewModelUser info, gallery grid, follow/unfollow, stories
Story viewerStoryViewModelTimed progression, swipe between authors
Create galleryCreateGalleryViewModelPhoto picker, EXIF extraction, upload queue, title/description/location
SearchSearchViewModelText search galleries + profiles, camera/location/hashtag discovery
NotificationsNotificationsViewModelPaginated list, 6 reason types
Edit profileSettingsViewModelDisplay name, description, avatar upload
CommentsCommentsViewModelThreaded replies, post comment
Followers/FollowingFollowListViewModelPaginated user lists
Camera feedFeedViewModelFiltered by camera name
Location feedFeedViewModelFiltered by H3 cell
Hashtag feedFeedViewModelFiltered by tag

Shared Patterns

  • Cursor-based pagination via loadMore()
  • Pull-to-refresh on all list views
  • Nuke LazyImage for all photo rendering with prefetching on scroll
  • Optimistic UI updates for favorites, follows, comments