Skip to content

Commit 8dd3479

Browse files
Adronclaude
andcommitted
Merge origin/main into feat/identity-token-health
One conflict, in APIClient.swift, resolved by taking this branch's version rather than keeping both: #90 (now on main) added a doc comment to the old two-argument `verifyIdentity(provider:providerId:)`, and this branch replaces that function outright with `verifyIdentity(provider:)` returning an `IdentityVerification` outcome. Main's copy is the version this supersedes, so keeping both would have left a stale duplicate and a signature that no longer compiles against the call site. Verified after resolving: only the single-argument form remains, and no `providerId` parameter survives anywhere in the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
2 parents 8a5c26c + 7cfec66 commit 8dd3479

44 files changed

Lines changed: 3203 additions & 282 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/commands/ios-review.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,35 @@ Perform a focused code review of the Swift/SwiftUI changes on the current branch
4343
### Code quality
4444
- [ ] No unnecessary comments (only "why", never "what")
4545
- [ ] No dead code, unused variables, or leftover `TODO` without a tracking issue
46+
- [ ] **Every new `APIClient` function has a call site outside `Services/APIClient*`.** An endpoint
47+
consumed only by a function no screen calls looks like coverage in an endpoint diff while
48+
being a missing feature — that is how "documents shared with me", "mute", "add org member"
49+
and "trash a DM" stayed invisible for a year. If it is deliberately unwired, say why in a
50+
comment on the function.
51+
- [ ] **Request-body keys match what the route destructures.** `PATCH /api/user/update` and friends
52+
ignore unknown keys and still answer `200`, so a mismatch is silent data loss, not an error.
53+
Read the route, don't infer the name.
4654

47-
4. **Summarize findings** as:
55+
4. **Run the zero-call-site sweep** (cheap, catches the above mechanically):
56+
```bash
57+
for f in InterlinedList/Services/APIClient*.swift; do
58+
grep -oE '^\s+(@discardableResult\s+)?func [a-zA-Z0-9_]+' "$f" | sed -E 's/.*func //'
59+
done | sort -u | while read -r fn; do
60+
n=$(grep -rn "\.${fn}(" InterlinedList/ | grep -vc "InterlinedList/Services/APIClient")
61+
[ "$n" -eq 0 ] && echo "zero app call sites: $fn"
62+
done
63+
```
64+
Transport helpers (`get`, `post*`, `put*`, `patch*`, `delete*`, `checkResponse`,
65+
`postMultipartRawData`, `pathSegment`, `serverErrorMessage`) are called via `Self.` inside the
66+
client and will always show up here — ignore them. Anything else needs wiring, deleting, or a
67+
comment explaining why it is kept.
68+
69+
5. **Summarize findings** as:
4870
- Blockers (must fix before merge)
4971
- Suggestions (non-blocking improvements)
5072
- Positives (good patterns worth noting)
5173

52-
5. **Run a build** to confirm there are no compilation errors. Prefer XcodeBuildMCP `build_sim` (after `session_show_defaults`); raw fallback pins a concrete UDID (`name=iPhone 16` alone is ambiguous across runtimes):
74+
6. **Run a build** to confirm there are no compilation errors. Prefer XcodeBuildMCP `build_sim` (after `session_show_defaults`); raw fallback pins a concrete UDID (`name=iPhone 16` alone is ambiguous across runtimes):
5375
```bash
5476
xcodebuild -scheme InterlinedList \
5577
-destination 'platform=iOS Simulator,id=<SIM_UDID>' \

InterlinedList.xcodeproj/project.pbxproj

Lines changed: 114 additions & 38 deletions
Large diffs are not rendered by default.

InterlinedList/InterlinedListApp.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct InterlinedListApp: App {
1313
@StateObject private var authState = AuthState()
1414
@StateObject private var store = AppDataStore()
1515
@StateObject private var router = AppRouter()
16+
@StateObject private var settingsSync = AppSettingsSyncService()
1617
@Environment(\.scenePhase) private var scenePhase
1718

1819
init() {
@@ -25,12 +26,17 @@ struct InterlinedListApp: App {
2526
.environmentObject(authState)
2627
.environmentObject(store)
2728
.environmentObject(router)
29+
.environmentObject(settingsSync)
2830
.onChange(of: authState.hasToken) { _, has in
2931
if has {
3032
PushService.shared.requestPermissionAndRegister()
33+
Task { await syncAppSettings() }
3134
} else {
3235
PushService.shared.unregister()
3336
store.reset()
37+
// The device id is deliberately NOT cleared — it belongs to
38+
// the install, not the session, and re-registering on every
39+
// sign-in would litter the account's device list.
3440
}
3541
}
3642
.onOpenURL { url in
@@ -132,6 +138,18 @@ struct InterlinedListApp: App {
132138
}
133139
}
134140

141+
/// Registers this install with the settings-sync service so it appears in the
142+
/// web's Settings → Applications, then mirrors the account-level preferences
143+
/// into the shared document so a fresh install elsewhere can bootstrap from it.
144+
@MainActor
145+
private func syncAppSettings() async {
146+
guard await settingsSync.registerDeviceIfNeeded() != nil else { return }
147+
_ = await settingsSync.loadSharedSettings()
148+
if let user = authState.user {
149+
await settingsSync.save(AppSettingsSyncService.sharedSettings(from: user))
150+
}
151+
}
152+
135153
@MainActor
136154
private func verifyEmail(token: String) async {
137155
do {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//
2+
// AppSettingsSync.swift
3+
// InterlinedList
4+
//
5+
6+
import Foundation
7+
8+
/// The app's key in the settings-sync service. Contract:
9+
/// https://interlinedlist.com/help/api/app-settings
10+
enum AppSettingsKey {
11+
static let appKey = "il-ios"
12+
static let platform = "ios"
13+
/// Bump when the meaning of a stored field changes so an older client can tell.
14+
static let schemaVersion = 1
15+
/// Server cap (`MAX_SETTINGS_BYTES`). Enforced client-side too — a write above
16+
/// it is a 413, which is a bug in what we chose to store, not a transient error.
17+
static let maxSettingsBytes = 64 * 1024
18+
}
19+
20+
/// The account-level (shared) settings iOS syncs. Deliberately small: preferences a
21+
/// second device should inherit, never cached content.
22+
///
23+
/// Anything phone-specific (per-device UI state, local-only toggles) belongs in the
24+
/// per-device document instead, which is why this type carries none of it.
25+
struct AppSharedSettings: Codable, Equatable {
26+
var theme: String?
27+
var defaultPubliclyVisible: Bool?
28+
var showAdvancedPostSettings: Bool?
29+
var viewingPreference: String?
30+
var messagesPerPage: Int?
31+
var showPreviews: Bool?
32+
var notificationTrayLimit: Int?
33+
34+
static let empty = AppSharedSettings()
35+
}
36+
37+
/// One settings document as the service stores it. `settings` is an opaque blob the
38+
/// server round-trips byte-for-byte, so it is modelled generically.
39+
struct SettingsDoc<Settings: Codable>: Codable {
40+
let appKey: String
41+
let scope: String?
42+
let deviceId: String?
43+
let version: Int
44+
let updatedAt: String?
45+
let schemaVersion: Int?
46+
let settings: Settings
47+
}
48+
49+
/// `GET …/bootstrap` — where a fresh install should start from.
50+
struct SettingsBootstrap<Settings: Codable>: Codable {
51+
let source: String
52+
let version: Int?
53+
let schemaVersion: Int?
54+
let settings: Settings?
55+
let defaultDeviceId: String?
56+
let defaultDeviceName: String?
57+
}
58+
59+
struct AppDevice: Codable, Identifiable {
60+
let deviceId: String
61+
let deviceName: String?
62+
let platform: String?
63+
let isDefault: Bool?
64+
let lastSeenAt: String?
65+
let appVersion: String?
66+
let osVersion: String?
67+
68+
var id: String { deviceId }
69+
}
70+
71+
struct AppDevicesResponse: Codable {
72+
let devices: [AppDevice]
73+
}
74+
75+
struct AppDeviceResponse: Codable {
76+
let device: AppDevice
77+
}
78+
79+
/// Raised when the server rejects a write because someone else wrote first. The
80+
/// caller must re-read and re-apply — last-write-wins is wrong here, and the server
81+
/// hands back the winning document so no extra round trip is needed.
82+
struct SettingsVersionConflict<Settings: Codable>: Error {
83+
let current: SettingsDoc<Settings>?
84+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// DMConversation.swift
3+
// InterlinedList
4+
//
5+
6+
import Foundation
7+
8+
/// One row of the conversation-grouped inbox (`GET /api/dm/conversations`) — one
9+
/// entry per correspondent rather than per message, with that conversation's own
10+
/// unread count.
11+
///
12+
/// Identified by `pairKey`, which is the server's stable per-conversation key, so
13+
/// a row keeps its identity as new messages arrive.
14+
struct DMConversation: Codable, Identifiable, Hashable {
15+
let pairKey: String
16+
let otherUser: DMUser
17+
let lastMessageId: String
18+
let lastBody: String?
19+
/// Server-rendered preview: markdown stripped, or `[image]` for an image-only
20+
/// message. Prefer this over `lastBody` so the row matches the web.
21+
let preview: String?
22+
let lastImageUrls: [String]?
23+
let lastCreatedAt: String
24+
/// True when the last message in the conversation was sent by this account.
25+
let isMine: Bool
26+
let unreadCount: Int
27+
28+
var id: String { pairKey }
29+
30+
/// Matches the web's cap (`ConversationList.tsx`).
31+
var unreadBadge: String? {
32+
guard unreadCount > 0 else { return nil }
33+
return unreadCount > 99 ? "99+" : String(unreadCount)
34+
}
35+
36+
var previewText: String {
37+
if let preview, !preview.isEmpty { return preview }
38+
if let lastBody, !lastBody.isEmpty { return lastBody }
39+
return (lastImageUrls?.isEmpty == false) ? "[image]" : ""
40+
}
41+
}
42+
43+
/// Keyset page. `nextCursor` is an opaque base64 token — never parse or build it
44+
/// client-side; pass back exactly what the server sent.
45+
struct DMConversationPage: Codable {
46+
let items: [DMConversation]
47+
let nextCursor: String?
48+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//
2+
// DocumentPresence.swift
3+
// InterlinedList
4+
//
5+
6+
import Foundation
7+
8+
/// Another editor currently active in a document. `anchor`/`head` are caret
9+
/// offsets; iOS does not mirror remote carets (out of scope for v1) but decodes
10+
/// them so the shape matches the route and a later version needs no migration.
11+
struct DocumentPresenceUser: Codable, Identifiable, Hashable {
12+
let userId: String
13+
let name: String
14+
let color: String?
15+
let anchor: Int?
16+
let head: Int?
17+
18+
var id: String { userId }
19+
}
20+
21+
/// Response to the combined heartbeat + poll. `users` excludes the caller.
22+
struct DocumentPresenceResponse: Codable {
23+
let users: [DocumentPresenceUser]
24+
/// The document's current server-side version — a cheap staleness signal.
25+
let version: Int?
26+
}

InterlinedList/Models/Message.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,23 @@ struct CreateMessageResponse: Codable {
238238
let data: Message?
239239
let crossPostResults: [CrossPostResult]?
240240
}
241+
242+
/// One platform's cross-post reply count, from `POST /api/messages/{id}/reply-counts`.
243+
/// The server caches for 10 minutes and backs unsupported platforms off for 24 hours,
244+
/// so the client never retries on its own.
245+
struct ReplyCountEntry: Codable, Identifiable {
246+
let platform: String
247+
let count: Int?
248+
/// `success`, `unsupported` or `error`. Only `success` entries are worth drawing.
249+
let status: String
250+
let checkedAt: String?
251+
252+
var id: String { platform }
253+
254+
var isDisplayable: Bool { status == "success" && count != nil }
255+
}
256+
257+
struct ReplyCountsResponse: Codable {
258+
let replyCounts: [ReplyCountEntry]
259+
let repliesCheckedAt: String?
260+
}

InterlinedList/Models/User.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ struct User: Codable, Identifiable {
2929
/// The user's default GitHub repo ("owner/repo") for GitHub-backed lists,
3030
/// or nil if none is set. Serialized camelCase by the API.
3131
let githubDefaultRepo: String?
32+
/// Private account: new followers need approval and posts stay visible only
33+
/// to approved followers. Drives the follow-request flow server-side.
34+
let isPrivateAccount: Bool?
35+
/// Server-side feed scope. One of `all_messages`, `following_only`,
36+
/// `followers_only`, `my_messages` — the messages and search routes build
37+
/// their visibility clause from this, so it changes what the feed returns.
38+
let viewingPreference: String?
39+
/// Feed page size (server accepts 10–30).
40+
let messagesPerPage: Int?
41+
/// Render link previews in the feed.
42+
let showPreviews: Bool?
43+
/// How many notifications `?scope=tray` returns (server accepts 10–40).
44+
let notificationTrayLimit: Int?
3245

3346
var displayNameOrUsername: String {
3447
displayName?.isEmpty == false ? (displayName ?? username) : username
@@ -44,7 +57,9 @@ struct User: Codable, Identifiable {
4457
avatar: String?, bio: String?, theme: String?, emailVerified: Bool?,
4558
createdAt: String?, maxMessageLength: Int?, showAdvancedPostSettings: Bool?,
4659
defaultPubliclyVisible: Bool?, customerStatus: String?,
47-
githubDefaultRepo: String? = nil) {
60+
githubDefaultRepo: String? = nil, isPrivateAccount: Bool? = nil,
61+
viewingPreference: String? = nil, messagesPerPage: Int? = nil,
62+
showPreviews: Bool? = nil, notificationTrayLimit: Int? = nil) {
4863
self.id = id
4964
self.email = email
5065
self.username = username
@@ -59,6 +74,11 @@ struct User: Codable, Identifiable {
5974
self.defaultPubliclyVisible = defaultPubliclyVisible
6075
self.customerStatus = customerStatus
6176
self.githubDefaultRepo = githubDefaultRepo
77+
self.isPrivateAccount = isPrivateAccount
78+
self.viewingPreference = viewingPreference
79+
self.messagesPerPage = messagesPerPage
80+
self.showPreviews = showPreviews
81+
self.notificationTrayLimit = notificationTrayLimit
6282
}
6383
}
6484

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//
2+
// ViewPreferences.swift
3+
// InterlinedList
4+
//
5+
6+
import Foundation
7+
8+
/// Server-side feed scope. `PATCH /api/user/update` rejects anything outside these
9+
/// four literals with a 400, and the messages/search routes build their visibility
10+
/// clause from the stored value — so this changes what the feed *returns*, not just
11+
/// how it is drawn.
12+
enum FeedScope: String, CaseIterable, Identifiable {
13+
case allMessages = "all_messages"
14+
case followingOnly = "following_only"
15+
case followersOnly = "followers_only"
16+
case myMessages = "my_messages"
17+
18+
var id: String { rawValue }
19+
20+
var label: String {
21+
switch self {
22+
case .allMessages: return "Everyone"
23+
case .followingOnly: return "People I follow"
24+
case .followersOnly: return "My followers"
25+
case .myMessages: return "Only me"
26+
}
27+
}
28+
29+
/// Unknown or missing values fall back to the server's own default rather than
30+
/// failing, so a value added server-side never breaks the picker.
31+
static func from(_ raw: String?) -> FeedScope {
32+
guard let raw, let scope = FeedScope(rawValue: raw) else { return .allMessages }
33+
return scope
34+
}
35+
}
36+
37+
/// The ranges `app/api/user/update/route.ts` validates. Mirrored here so the
38+
/// steppers cannot produce a value the route would 400 on.
39+
enum ViewPreferenceBounds {
40+
static let messagesPerPage = 10...30
41+
static let notificationTrayLimit = 10...40
42+
43+
/// Feed page size when the account has never set one. The feed shipped with a
44+
/// hardcoded 50, which is above the settable maximum — preserved as the
45+
/// unset-default so existing installs do not silently start paging smaller.
46+
static let defaultMessagesPerPage = 50
47+
static let defaultNotificationTrayLimit = 20
48+
49+
static func clamp(_ value: Int, to range: ClosedRange<Int>) -> Int {
50+
min(max(value, range.lowerBound), range.upperBound)
51+
}
52+
}

0 commit comments

Comments
 (0)