Skip to content
23 changes: 22 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,28 @@
"Bash(git ls-remote *)",
"Bash(gh pr *)",
"Bash(git push *)",
"Bash(echo \"---exit $?\")"
"Bash(echo \"---exit $?\")",
"Bash(git remote *)",
"Bash(sort -t: -k1,1 -k2,2n)",
"Bash(gh run *)",
"Bash(git *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" https://interlinedlist.com/terms)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" https://interlinedlist.com/guidelines)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" https://interlinedlist.com/community)",
"Bash(curl -s -X POST https://interlinedlist.com/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"messenger@interlinedlist.com\",\"password\":\"metal69!\"}')",
"Bash(curl -s -i -X POST https://interlinedlist.com/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"messenger@interlinedlist.com\",\"password\":\"metal69!\"}')",
"Bash(curl -s -X POST https://interlinedlist.com/api/auth/sync-token -H 'Content-Type: application/json' -d '{\"email\":\"messenger@interlinedlist.com\",\"password\":\"metal69!\"}')",
"Bash(curl -s https://interlinedlist.com/api/user -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')",
"Bash(curl -s https://interlinedlist.com/api/messages?limit=2 -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')",
"Bash(curl -s https://interlinedlist.com/api/messages?limit=5 -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')",
"Bash(curl -s -o /tmp/blocks.json -w '%{http_code}' https://interlinedlist.com/api/user/blocks -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')",
"Bash(curl -s -o /tmp/identities.json -w '%{http_code}' https://interlinedlist.com/api/user/identities -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')",
"Bash(curl -s -o /tmp/push.json -w '%{http_code}' -X POST https://interlinedlist.com/api/push/register -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb' -H 'Content-Type: application/json' -d '{\"token\":\"aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233\",\"platform\":\"ios\"}')",
"Bash(curl -s https://interlinedlist.com/api/user/organizations -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')",
"Bash(curl -s -o /tmp/avatar.json -w '%{http_code}' -X POST https://interlinedlist.com/api/user/avatar/from-url -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb' -H 'Content-Type: application/json' -d '{\"url\":\"https://example.com/fake.jpg\"}')",
"Bash(curl -s https://interlinedlist.com/api/messages?limit=20 -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')",
"Bash(curl -s -o /tmp/msg_org.json -w '%{http_code}' -X POST https://interlinedlist.com/api/messages -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb' -H 'Content-Type: application/json' -d '{\"organizationId\":\"00000000-0000-0000-0000-000000000000\"}')",
"Bash(curl -s https://interlinedlist.com/api/folders -H 'Authorization: Bearer f3fc370ad110b4fe5b002772d70eac3eab3d29e77949cd45c4033565fc15c0eb')"
],
"additionalDirectories": [
"/Users/adron/Codez/interlinedlist-ios/.claude"
Expand Down
109 changes: 109 additions & 0 deletions App-Store-Deployment-Checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# App Store Deployment — Pre-Flight Checklist

Last updated: 2026-07-08

---

## Feature gates

- [x] Phase 0.5 — Info.plist hygiene done (`ITSAppUsesNonExemptEncryption`, `arm64`, icon)
- [x] Phase 9 — Push notifications wired (PushService, register/unregister, `aps-environment: development` entitlement in place; Xcode auto-signing upgrades to production on archive)
- [x] Phase 14 — UGC safety shipped (report message/user, block/unblock, mute, terms gate on register, blocked users in settings)
- [x] Unit tests — All 8 moderation API methods have bearer token + 401 error tests; `ModerationModelTests` covers `BlockedUser`/`MutedUser` Codable decoding

---

## Accounts & credentials

| Item | Status | Notes |
|---|---|---|
| Apple Developer Program membership active; agreements accepted in ASC | ☐ | $99/yr — verify at developer.apple.com |
| Your role on team `BJA9558E4B` is Admin or App Manager | ☐ | Check in App Store Connect |
| APNs Auth Key (.p8) created, Key ID recorded, handed to backend | ☐ | Portal → Certificates, IDs & Profiles → Keys → enable APNs; one-time download |
| Demo reviewer account registered and confirmed working on production | ☐ | Register at interlinedlist.com with email/password; keep creds in password manager |
| Privacy Policy URL live | ✅ | `https://interlinedlist.com/privacy` → 200 (verified 2026-07-07) |
| Support URL live | ✅ | `https://interlinedlist.com/help` → 307 redirect (acceptable for ASC) |
| Community Guidelines / EULA URL live and linked from `RegisterView` | ✅ | `https://interlinedlist.com/terms` → 200 (verified 2026-07-07) |

---

## Xcode project

| Item | Status | Notes |
|---|---|---|
| App ID `com.interlinedlist.app` registered in portal with Push Notifications enabled | ☐ | Portal → Identifiers → App IDs |
| Automatic signing, team `BJA9558E4B`, archive signs cleanly | ☐ | Target → Signing & Capabilities |
| No unused capabilities or entitlements | ☐ | Only `aps-environment` is present |
| Build number incremented from any prior upload | ☐ | `agvtool next-version -all` |

---

## App Store Connect record

| Item | Status | Notes |
|---|---|---|
| App record created (name: InterlinedList, bundle ID, SKU: `interlinedlist-ios`, Full access) | ☐ | appstoreconnect.apple.com → Apps → "+" |
| Free pricing, all territories selected | ☐ | Pricing and Availability tab |
| Primary category: Social Networking | ☐ | App Information tab |
| Privacy Policy URL entered | ☐ | `https://interlinedlist.com/privacy` |
| Support URL entered | ☐ | `https://interlinedlist.com/help` |
| Age rating questionnaire completed | ☐ | Fill after Phase 14 confirmed — expected 17+ |

---

## Assets

| Item | Status | Notes |
|---|---|---|
| App icon: no empty wells, no alpha channel (verified in Xcode asset catalog) | ☐ | `InterlinedList/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png` |
| Screenshots: 6.9" set uploaded (iPhone 16 Pro Max, 1320 × 2868 px) | ☐ | Minimum screens: Feed, Compose, Lists, Profile, Settings |
| Screenshots: 6.5" set uploaded (iPhone 15 Plus, 1242 × 2688 px) | ☐ | Same minimum screens |
| App name (≤30 chars): "InterlinedList" | ☐ | Verify uniqueness in ASC at record creation |
| Subtitle (≤30 chars) | ☐ | e.g. "Social lists, shared" |
| Description (≤4,000 chars) | ☐ | |
| Keywords (≤100 chars total, comma-separated) | ☐ | |
| Promotional text (≤170 chars) | ☐ | Changeable without a new submission |
| What's New: "Initial release." | ☐ | |
| App Privacy nutrition label completed and saved in ASC | ☐ | See §4 in `App-Store-Deployment.md` for declared data types |

---

## Upload & review

| Item | Status | Notes |
|---|---|---|
| Build archived (Release, Any iOS Device / arm64) | ☐ | Product → Archive in Xcode |
| Build uploaded and processed (visible in TestFlight) | ☐ | Distribute App → App Store Connect → Upload |
| TestFlight smoke-test passed on a real device | ☐ | See smoke-test checklist below |
| Review notes written | ☐ | See review notes template below |
| Submit for Review | ☐ | |

### Smoke-test checklist (real device, before submitting)

- [ ] Email/password login and registration
- [ ] OAuth (at least one provider — Mastodon or Bluesky)
- [ ] Compose + post (text, image)
- [ ] Feed scroll, dig/undig, reply
- [ ] Lists and Documents CRUD
- [ ] Organizations list loads (Bearer auth fix shipped 2026-07-07)
- [ ] Deep-link callbacks (`interlinedlist://reset-password`, `interlinedlist://verify-email`)
- [ ] Push notification receipt and tap routing
- [ ] Settings, sign-out, delete-account flow
- [ ] Report a message (tap `...` on any post → Report)
- [ ] Block a user (tap `...` on any post or visit their profile → Block)

### Review notes template

```
Demo login:
Email: <demo-account-email>
Password: <demo-account-password>

Subscriptions are managed exclusively at interlinedlist.com.
There is no in-app purchase, paywall, or billing UI in this app.

To delete the account: Profile → Edit Profile → Delete Account (double-confirm).

To report content: tap the … menu on any post → Report.
To block a user: tap the … menu on any post or visit their profile → Block.
```
11 changes: 9 additions & 2 deletions App-Store-Deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ The following phases are complete and in the current build:

**Shipped since last update (2026-07-05):** Phase 0.5 (Info.plist: arm64, ITSAppUsesNonExemptEncryption), Phase 14 (UGC safety: report message/user, block/unblock user, mute, terms acceptance on register, blocked users in settings), Phase 9 (push notifications: PushService, APNs delegate, register/unregister device token).

**Shipped 2026-07-07 (backend sync + iOS follow-up):**
- `GET /api/user/organizations` now accepts Bearer tokens → `OrganizationListView` unblocked; no iOS workaround needed.
- `GET /api/lists/{id}/watchers/me` now returns `role` field → `WatchingResponse` model updated; `isWatchingList` returns full response.
- Self-watch via `POST /api/lists/{id}/watchers` no longer requires `userId` in body → `watchSelf(listId:)` added; `PublicListDetailView` uses it.
- Avatar update flow no longer issues trailing `GET /api/user`; uses `PATCH /api/user/update` response instead.
- Moderation unit tests expanded (bearer token + error handling tests for all 8 methods); `ModerationModelTests` added with Codable decode coverage for `BlockedUser`, `MutedUser`, and their response wrappers.

---

## 1. Feature Completion — What Must Ship Before Submission
Expand Down Expand Up @@ -86,7 +93,7 @@ iOS work (after backend is confirmed):
- [x] Surface Terms + Community Guidelines links in `SettingsView` → About
- [x] New files: `Views/ReportSheet.swift`, `Views/BlockedUsersView.swift`, `Models/Moderation.swift`, `Services/PushService.swift`
- [x] New `APIClient` methods: `reportMessage`, `reportUser`, `blockUser`, `unblockUser`, `blockedUsers`, `muteUser`, `unmuteUser`, `mutedUsers`
- [ ] Unit tests (MockURLSession) for all new API methods; decoding tests for new models
- [x] Unit tests (MockURLSession) for all new API methods; decoding tests for new models
- [x] `#Preview` for all new views; `.accessibilityLabel` on all new controls

#### Phase 0.5 — Info.plist Hygiene `Tiny` ⛔
Expand Down Expand Up @@ -407,7 +414,7 @@ Copy this and tick it off just before submitting.
**Feature gates**
- [x] Phase 14 — UGC safety shipped (report, block, mute, terms gate)
- [x] Phase 0.5 — Info.plist hygiene done (`ITSAppUsesNonExemptEncryption`, `arm64`, icon)
- [x] Phase 9 — Push notifications wired (PushService, register/unregister); APNs capability still needs adding in Xcode
- [x] Phase 9 — Push notifications wired (PushService, register/unregister, `aps-environment: development` entitlement in place; Xcode auto-signing upgrades to production on archive)

**Accounts & credentials**
- [ ] Apple Developer Program membership active; agreements accepted in ASC
Expand Down
4 changes: 4 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
T1E5T1E5T1E5P023 /* APIClientDeleteAccountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */; };
T1E5T1E5T1E5P025 /* OrganizationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */; };
T1E5T1E5T1E5P027 /* LinkedIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */; };
T1E5T1E5T1E5P029 /* ModerationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E5P030 /* ModerationModelTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -235,6 +236,7 @@
T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDeleteAccountTests.swift; sourceTree = "<group>"; };
T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizationModelTests.swift; sourceTree = "<group>"; };
T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentityModelTests.swift; sourceTree = "<group>"; };
T1E5T1E5T1E5P030 /* ModerationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModerationModelTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -436,6 +438,7 @@
T1E5T1E5T1E5P008 /* ListSchemaDraftTests.swift */,
T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */,
T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */,
T1E5T1E5T1E5P030 /* ModerationModelTests.swift */,
93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */,
);
path = ModelTests;
Expand Down Expand Up @@ -653,6 +656,7 @@
T1E5T1E5T1E5P023 /* APIClientDeleteAccountTests.swift in Sources */,
T1E5T1E5T1E5P025 /* OrganizationModelTests.swift in Sources */,
T1E5T1E5T1E5P027 /* LinkedIdentityModelTests.swift in Sources */,
T1E5T1E5T1E5P029 /* ModerationModelTests.swift in Sources */,
23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */,
6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */,
E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion InterlinedList/Models/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ struct UserList: Identifiable, Codable, Hashable {
}
}

struct ListFolder: Identifiable, Codable {
struct ListFolder: Identifiable, Codable, Equatable {
let id: String
let name: String
let parentId: String?
Expand Down
1 change: 1 addition & 0 deletions InterlinedList/Models/ListWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,5 @@ struct WatcherCandidatesResponse: Decodable {

struct WatchingResponse: Decodable {
let watching: Bool
let role: String?
}
7 changes: 4 additions & 3 deletions InterlinedList/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,13 @@ struct CreateMessageBody: Encodable {

/// One platform's result after a cross-post attempt. Surfaced in a post-publish toast.
/// Best-effort: the create response may or may not include this depending on deployment.
/// All fields are optional because the server shape is inconsistent across deployments.
struct CrossPostResult: Codable, Identifiable {
let platform: String
let success: Bool
let platform: String?
let success: Bool?
let error: String?

var id: String { platform }
var id: String { platform ?? error ?? UUID().uuidString }
}

struct CreateMessageResponse: Codable {
Expand Down
32 changes: 26 additions & 6 deletions InterlinedList/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ final class APIClient {
let provider: String
let providerUsername: String?
let createdAt: String?

/// Base provider name without any instance suffix (e.g. "mastodon:techhub.social" → "mastodon").
var providerType: String { String(provider.prefix(while: { $0 != ":" })) }
}

func linkedIdentities() async throws -> [LinkedIdentity] {
Expand Down Expand Up @@ -206,14 +209,25 @@ final class APIClient {
request.httpBody = body
let (responseData, response) = try await session.data(for: request)
try checkResponse(data: responseData, response: response)
// Endpoint returns { url } only; refresh the user object to satisfy the signature.
struct UploadResp: Decodable { let url: String? }
if let avatarUrl = (try? decoder.decode(UploadResp.self, from: responseData))?.url {
return try await applyAvatarUrl(avatarUrl)
}
return try await currentUser()
}

func setAvatarFromURL(_ url: String) async throws -> User {
func setAvatarFromURL(_ avatarUrl: String) async throws -> User {
struct Body: Encodable { let url: String }
struct Response: Decodable { let url: String? }
let _: Response = try await post("/api/user/avatar/from-url", body: Body(url: url))
let resp: Response = try await post("/api/user/avatar/from-url", body: Body(url: avatarUrl))
return try await applyAvatarUrl(resp.url ?? avatarUrl)
}

private func applyAvatarUrl(_ url: String) async throws -> User {
struct Body: Encodable { let avatar: String }
struct Resp: Decodable { let user: User? }
let wrapped: Resp = try await post("/api/user/update", body: Body(avatar: url))
if let user = wrapped.user { return user }
return try await currentUser()
}

Expand Down Expand Up @@ -907,10 +921,16 @@ final class APIClient {
return response.watchers
}

func isWatchingList(listId: String) async throws -> Bool {
func isWatchingList(listId: String) async throws -> WatchingResponse {
let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId
return try await get("/api/lists/\(encoded)/watchers/me")
}

func watchSelf(listId: String) async throws {
struct Empty: Encodable {}
struct Response: Decodable { let watching: Bool? }
let encoded = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId
let response: WatchingResponse = try await get("/api/lists/\(encoded)/watchers/me")
return response.watching
let _: Response = try await postCamel("/api/lists/\(encoded)/watchers", body: Empty())
}

func searchWatcherCandidates(listId: String, limit: Int = 20, offset: Int = 0) async throws -> [WatcherCandidate] {
Expand Down
Loading
Loading