1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# 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.
```swift
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
| Screen | ViewModel | Notes |
|--------|-----------|-------|
| Home feed | `FeedViewModel` | Paginated galleries, pull-to-refresh, cursor pagination |
| Following feed | `FeedViewModel` | Reused with different feed param |
| Gallery detail | `GalleryViewModel` | Photos, comment thread, favorite toggle |
| Profile | `ProfileViewModel` | User info, gallery grid, follow/unfollow, stories |
| Story viewer | `StoryViewModel` | Timed progression, swipe between authors |
| Create gallery | `CreateGalleryViewModel` | Photo picker, EXIF extraction, upload queue, title/description/location |
| Search | `SearchViewModel` | Text search galleries + profiles, camera/location/hashtag discovery |
| Notifications | `NotificationsViewModel` | Paginated list, 6 reason types |
| Edit profile | `SettingsViewModel` | Display name, description, avatar upload |
| Comments | `CommentsViewModel` | Threaded replies, post comment |
| Followers/Following | `FollowListViewModel` | Paginated user lists |
| Camera feed | `FeedViewModel` | Filtered by camera name |
| Location feed | `FeedViewModel` | Filtered by H3 cell |
| Hashtag feed | `FeedViewModel` | Filtered 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