Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .claude/settings.local.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -436,6 +438,7 @@
T1E5T1E5T1E5P008 /* ListSchemaDraftTests.swift */,
T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */,
T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */,
T1E5T1E5T1E5P030 /* ModerationModelTests.swift */,
93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */,
);
path = ModelTests;
Expand DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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