diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9d7686e..76d0594 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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" diff --git a/App-Store-Deployment-Checklist.md b/App-Store-Deployment-Checklist.md new file mode 100644 index 0000000..cbb1c2a --- /dev/null +++ b/App-Store-Deployment-Checklist.md @@ -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: + 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. +``` diff --git a/App-Store-Deployment.md b/App-Store-Deployment.md index a6d837c..436a16e 100644 --- a/App-Store-Deployment.md +++ b/App-Store-Deployment.md @@ -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 @@ -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` ⛔ @@ -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 diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index a6fa370..2131fd6 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -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 */ @@ -235,6 +236,7 @@ T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDeleteAccountTests.swift; sourceTree = ""; }; T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizationModelTests.swift; sourceTree = ""; }; T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentityModelTests.swift; sourceTree = ""; }; + T1E5T1E5T1E5P030 /* ModerationModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModerationModelTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -436,6 +438,7 @@ T1E5T1E5T1E5P008 /* ListSchemaDraftTests.swift */, T1E5T1E5T1E5P026 /* OrganizationModelTests.swift */, T1E5T1E5T1E5P028 /* LinkedIdentityModelTests.swift */, + T1E5T1E5T1E5P030 /* ModerationModelTests.swift */, 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */, ); path = ModelTests; @@ -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 */, diff --git a/InterlinedList/Models/List.swift b/InterlinedList/Models/List.swift index 4771547..def61ff 100644 --- a/InterlinedList/Models/List.swift +++ b/InterlinedList/Models/List.swift @@ -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? diff --git a/InterlinedList/Models/ListWatcher.swift b/InterlinedList/Models/ListWatcher.swift index f3fd2ba..432d196 100644 --- a/InterlinedList/Models/ListWatcher.swift +++ b/InterlinedList/Models/ListWatcher.swift @@ -94,4 +94,5 @@ struct WatcherCandidatesResponse: Decodable { struct WatchingResponse: Decodable { let watching: Bool + let role: String? } diff --git a/InterlinedList/Models/Message.swift b/InterlinedList/Models/Message.swift index e404510..d54286a 100644 --- a/InterlinedList/Models/Message.swift +++ b/InterlinedList/Models/Message.swift @@ -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 { diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 6d6377c..f6958bd 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -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] { @@ -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() } @@ -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] { diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index 4592d64..b90685a 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -24,13 +24,13 @@ final class AppDataStore: ObservableObject { @Published private(set) var unreadCount = 0 @Published private(set) var pendingRequestCount = 0 - private let cache = DataCache.shared + private let cache = DataCache() private var userId: String? func prefetchAll(userId: String?) async { if let uid = userId, self.userId != uid { self.userId = uid - loadFromCache(userId: uid) + await loadFromCache(userId: uid) } await withTaskGroup(of: Void.self) { group in group.addTask { await self.refreshFeed() } @@ -43,8 +43,9 @@ final class AppDataStore: ObservableObject { func onUserIdAvailable(_ id: String) { guard userId != id else { return } userId = id - if feedMessages.isEmpty { loadFromCache(userId: id) } - saveToCache() + if feedMessages.isEmpty { + Task { await loadFromCache(userId: id) } + } } func refreshFeed() async { @@ -54,7 +55,7 @@ final class AppDataStore: ObservableObject { do { let (list, _) = try await APIClient.shared.messages(limit: 50, offset: 0, onlyMine: false, tag: nil) feedMessages = list - saveToCache() + saveFeedCache() } catch APIError.status(401) { } catch APIError.server(let msg) { if feedMessages.isEmpty { feedError = msg } @@ -71,7 +72,7 @@ final class AppDataStore: ObservableObject { let result = try await APIClient.shared.listsAndFolders() listFolders = result.folders userLists = result.lists - saveToCache() + saveListsCache() } catch APIError.status(401) { } catch APIError.server(let msg) { if userLists.isEmpty { listsError = msg } @@ -90,7 +91,7 @@ final class AppDataStore: ObservableObject { let (f, d) = try await (fTask, dTask) documentFolders = f documents = d - saveToCache() + saveDocsCache() } catch APIError.status(401) { } catch { // GET /api/documents is not documented as subscriber-only, so a @@ -119,20 +120,20 @@ final class AppDataStore: ObservableObject { func insertFeedMessage(_ message: Message) { feedMessages.insert(message, at: 0) - saveToCache() + saveFeedCache() } - func removeList(id: String) { userLists.removeAll { $0.id == id }; saveToCache() } - func removeListFolder(id: String) { listFolders.removeAll { $0.id == id }; saveToCache() } + func removeList(id: String) { userLists.removeAll { $0.id == id }; saveListsCache() } + func removeListFolder(id: String) { listFolders.removeAll { $0.id == id }; saveListsCache() } - func insertDocument(_ doc: Document) { documents.insert(doc, at: 0); saveToCache() } + func insertDocument(_ doc: Document) { documents.insert(doc, at: 0); saveDocsCache() } func updateDocument(_ doc: Document) { if let idx = documents.firstIndex(where: { $0.id == doc.id }) { documents[idx] = doc } - saveToCache() + saveDocsCache() } - func removeDocument(id: String) { documents.removeAll { $0.id == id }; saveToCache() } - func insertDocumentFolder(_ folder: DocumentFolder) { documentFolders.append(folder); saveToCache() } - func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; saveToCache() } + func removeDocument(id: String) { documents.removeAll { $0.id == id }; saveDocsCache() } + func insertDocumentFolder(_ folder: DocumentFolder) { documentFolders.append(folder); saveDocsCache() } + func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; saveDocsCache() } func reset() { feedMessages = [] @@ -153,23 +154,34 @@ final class AppDataStore: ObservableObject { // MARK: - Cache - private func loadFromCache(userId: String) { - if let msgs: [Message] = cache.load(key: "\(userId)_feed") { feedMessages = msgs } - if let cached: ListsCache = cache.load(key: "\(userId)_lists") { + private func loadFromCache(userId: String) async { + if let msgs: [Message] = await cache.load(key: "\(userId)_feed") { feedMessages = msgs } + if let cached: ListsCache = await cache.load(key: "\(userId)_lists") { listFolders = cached.folders userLists = cached.lists } - if let cached: DocsCache = cache.load(key: "\(userId)_docs") { + if let cached: DocsCache = await cache.load(key: "\(userId)_docs") { documentFolders = cached.folders documents = cached.documents } } - private func saveToCache() { + private func saveFeedCache() { + guard let uid = userId else { return } + let snapshot = feedMessages + Task { await cache.save(snapshot, key: "\(uid)_feed") } + } + + private func saveListsCache() { + guard let uid = userId else { return } + let snapshot = ListsCache(folders: listFolders, lists: userLists) + Task { await cache.save(snapshot, key: "\(uid)_lists") } + } + + private func saveDocsCache() { guard let uid = userId else { return } - cache.save(feedMessages, key: "\(uid)_feed") - cache.save(ListsCache(folders: listFolders, lists: userLists), key: "\(uid)_lists") - cache.save(DocsCache(folders: documentFolders, documents: documents), key: "\(uid)_docs") + let snapshot = DocsCache(folders: documentFolders, documents: documents) + Task { await cache.save(snapshot, key: "\(uid)_docs") } } } diff --git a/InterlinedList/Services/DataCache.swift b/InterlinedList/Services/DataCache.swift index 8724bbb..5d198b6 100644 --- a/InterlinedList/Services/DataCache.swift +++ b/InterlinedList/Services/DataCache.swift @@ -5,14 +5,12 @@ import Foundation -final class DataCache { - static let shared = DataCache() - +actor DataCache { private let encoder = JSONEncoder() private let decoder = JSONDecoder() private let cacheDir: URL - private init() { + init() { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] cacheDir = base.appendingPathComponent("ILDataCache", isDirectory: true) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index d35d33f..286d830 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -5,6 +5,9 @@ import SwiftUI import PhotosUI +import OSLog + +private let composeLog = Logger(subsystem: Bundle.main.bundleIdentifier ?? "InterlinedList", category: "ComposeView") /// Fallback when user's maxMessageLength is not available (matches API default). private let defaultMaxMessageLength = 666 @@ -106,7 +109,7 @@ struct ComposeView: View { } if !isReply && !isRepost && !postableOrgs.isEmpty { - Section("Post as") { + Section("Post Message As") { Picker("Author", selection: $selectedOrgId) { Text("Yourself").tag(String?.none) ForEach(postableOrgs) { org in @@ -200,13 +203,13 @@ struct ComposeView: View { private var navTitle: String { if isReply { return "Reply" } if isRepost { return "Repost" } - return "New post" + return "New Message" } private var postButtonLabel: String { if isRepost { return "Repost" } if scheduledDate != nil { return "Schedule" } - return isReply ? "Reply" : "Post" + return isReply ? "Reply" : "Post Message" } /// Reposts may have empty commentary; everything else requires content. @@ -217,7 +220,7 @@ struct ComposeView: View { private var successTitle: String { if isReply { return "Replied" } if isRepost { return "Reposted" } - return scheduledDate != nil ? "Scheduled" : "Posted" + return scheduledDate != nil ? "Scheduled" : "Message Posted" } private var successMessage: String { @@ -227,8 +230,17 @@ struct ComposeView: View { else if scheduledDate != nil { base = "Your message has been scheduled." } else { base = "Your message was posted." } if !lastCrossPostResults.isEmpty { - let summary = lastCrossPostResults.map { r in - "\(r.platform.capitalized) \(r.success ? "✓" : "✗")" + let summary = lastCrossPostResults.map { r -> String in + let succeeded = r.success ?? false + let label = r.platform?.capitalized ?? "Cross-post" + let status = succeeded ? "✓" : "✗" + let detail: String + if !succeeded, let msg = r.error, !msg.isEmpty { + detail = " (\(msg))" + } else { + detail = "" + } + return "\(label) \(status)\(detail)" }.joined(separator: " · ") return base + "\n" + summary } @@ -304,7 +316,7 @@ struct ComposeView: View { .foregroundStyle(scheduledDate != nil ? ILColor.primary : Color.secondary) } .buttonStyle(.borderless) - .accessibilityLabel(scheduledDate != nil ? "Clear schedule" : "Schedule post") + .accessibilityLabel(scheduledDate != nil ? "Clear schedule" : "Schedule message") } } Spacer(minLength: 0) @@ -389,10 +401,10 @@ struct ComposeView: View { // MARK: - Cross-post controls - private var mastodonIdentities: [APIClient.LinkedIdentity] { allIdentities.filter { $0.provider == "mastodon" } } - private var hasBluesky: Bool { allIdentities.contains { $0.provider == "bluesky" } } - private var hasLinkedIn: Bool { allIdentities.contains { $0.provider == "linkedin" } } - private var hasTwitter: Bool { allIdentities.contains { $0.provider == "twitter" } } + private var mastodonIdentities: [APIClient.LinkedIdentity] { allIdentities.filter { $0.providerType == "mastodon" } } + private var hasBluesky: Bool { allIdentities.contains { $0.providerType == "bluesky" } } + private var hasLinkedIn: Bool { allIdentities.contains { $0.providerType == "linkedin" } } + private var hasTwitter: Bool { allIdentities.contains { $0.providerType == "twitter" } } private var hasMastodon: Bool { !mastodonIdentities.isEmpty } private var hasCrossPostTargets: Bool { hasBluesky || hasLinkedIn || hasTwitter || hasMastodon } @@ -585,6 +597,7 @@ struct ComposeView: View { } catch APIError.status(403) { errorMessage = "You may need to verify your email before posting." } catch { + composeLog.error("postMessage failed: \(error)") errorMessage = "Connection failed. Please try again." } } diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index cfaf880..1c174ee 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -108,8 +108,14 @@ struct DocumentsView: View { } } label: { Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) } .accessibilityLabel("New item") + .buttonStyle(.plain) } } .refreshable { await store.refreshDocuments() } @@ -335,8 +341,14 @@ private struct DocumentFolderView: View { } } label: { Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) } .accessibilityLabel("New item in \(folder.name)") + .buttonStyle(.plain) } } .refreshable { await load() } @@ -541,7 +553,14 @@ private struct DocumentDetailView: View { Button("Delete", role: .destructive) { showDeleteConfirm = true } } label: { Image(systemName: "ellipsis.circle") - } + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) + } + .accessibilityLabel("Document options") + .buttonStyle(.plain) } } .sheet(isPresented: $showEdit) { diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index dd56ca3..3443dc7 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -227,12 +227,21 @@ struct FeedView: View { } return nav .onChange(of: store.feedMessages.count) { _, _ in - guard !syncedFromStore && !showOnlyMine && tagFilter == nil else { return } - let msgs = store.feedMessages - messages = msgs - initDigStates(from: msgs) - isLoading = false - syncedFromStore = !msgs.isEmpty + guard !showOnlyMine && tagFilter == nil else { return } + if !syncedFromStore { + let msgs = store.feedMessages + messages = msgs + initDigStates(from: msgs) + isLoading = false + syncedFromStore = !msgs.isEmpty + } else { + let existingIds = Set(messages.map { $0.id }) + let newMessages = store.feedMessages.filter { !existingIds.contains($0.id) } + if !newMessages.isEmpty { + messages.insert(contentsOf: newMessages, at: 0) + initDigStates(from: newMessages) + } + } } .onChange(of: store.feedLoading) { _, loading in guard !showOnlyMine && tagFilter == nil else { return } @@ -255,17 +264,33 @@ struct FeedView: View { Text("InterlinedList").font(.ilTitle()).foregroundStyle(.white) } } - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 4) { - // Scheduled posts are a subscriber-only feature; entry point hidden - // entirely for free users per the iOS-free-app direction. - if authState.user?.isSubscriber == true { - Button { showScheduled = true } label: { Image(systemName: "calendar") } - .accessibilityLabel("Scheduled posts") + // Scheduled posts are a subscriber-only feature; entry point hidden + // entirely for free users per the iOS-free-app direction. + if authState.user?.isSubscriber == true { + ToolbarItem(placement: .topBarTrailing) { + Button { showScheduled = true } label: { + Image(systemName: "calendar") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(.white.opacity(0.15)) + .clipShape(Circle()) } - Button { showCompose = true } label: { Image(systemName: "square.and.pencil") } - .accessibilityLabel("Compose") + .buttonStyle(.plain) + .accessibilityLabel("Scheduled posts") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { showCompose = true } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(.white.opacity(0.15)) + .clipShape(Circle()) } + .buttonStyle(.plain) + .accessibilityLabel("Compose") } } diff --git a/InterlinedList/Views/LinkedIdentitiesView.swift b/InterlinedList/Views/LinkedIdentitiesView.swift index a80cfa4..26594f8 100644 --- a/InterlinedList/Views/LinkedIdentitiesView.swift +++ b/InterlinedList/Views/LinkedIdentitiesView.swift @@ -108,7 +108,7 @@ struct LinkedIdentitiesView: View { @ViewBuilder private func identityRow(_ identity: APIClient.LinkedIdentity) -> some View { HStack(spacing: 12) { - Image(systemName: OAuthProvider(rawValue: identity.provider)?.systemImageName ?? "link") + Image(systemName: OAuthProvider(rawValue: identity.providerType)?.systemImageName ?? "link") .frame(width: 24) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { @@ -131,7 +131,7 @@ struct LinkedIdentitiesView: View { } private func displayName(for provider: String) -> String { - OAuthProvider(rawValue: provider)?.displayName ?? provider.capitalized + OAuthProvider(rawValue: String(provider.prefix(while: { $0 != ":" })))?.displayName ?? provider.capitalized } private func load() async { diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index 3607f12..26a510c 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -14,12 +14,13 @@ struct ListsView: View { @State private var searchText = "" @State private var searchResults: [UserList] = [] @State private var isSearching = false + @State private var treeNodes: [ListTreeNode] = [] private var canCreateFolders: Bool { authState.user?.isSubscriber == true } - private var treeNodes: [ListTreeNode] { + private func rebuildTree() -> [ListTreeNode] { // Folders are a subscriber-only feature. For free users we pass an empty // folder array so any lists that were nested under folders (e.g. from when // the user was a subscriber) surface at root via buildTree's orphan rule. @@ -29,43 +30,7 @@ struct ListsView: View { var body: some View { NavigationStack { - Group { - if !searchText.isEmpty { - searchResultsList - } else if store.listsLoading && treeNodes.isEmpty { - ListSkeletonView() - } else if let error = store.listsError, treeNodes.isEmpty { - ContentUnavailableView { - Label("Unable to load", systemImage: "exclamationmark.triangle") - } description: { - Text(error) - } actions: { - Button("Retry") { - Task { await store.refreshLists() } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if treeNodes.isEmpty { - ContentUnavailableView { - Label("No Lists", systemImage: "list.bullet.rectangle") - } description: { - Text("No lists found.") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List { - ForEach(treeNodes) { node in - ListTreeNodeRow( - node: node, - onDeleteList: { list in Task { await deleteList(list) } }, - onDeleteFolder: { folder in Task { await deleteFolder(folder) } }, - onUpdateList: { list in Task { await store.refreshLists() } } - ) - } - } - .listStyle(.sidebar) - } - } + listContent .navigationTitle("Lists") .navigationBarTitleDisplayMode(.large) .navigationDestination(for: UserList.self) { list in @@ -82,25 +47,7 @@ struct ListsView: View { } } .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { - showCreateList = true - } label: { - Label("New List", systemImage: "plus.rectangle") - } - if canCreateFolders { - Button { - showCreateFolder = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - } - } label: { - Image(systemName: "plus") - } - .accessibilityLabel("New item") - } + ToolbarItem(placement: .topBarTrailing) { addMenu } } .sheet(isPresented: $showCreateList) { CreateListView { _ in @@ -115,9 +62,72 @@ struct ListsView: View { .refreshable { await store.refreshLists() } + .onAppear { treeNodes = rebuildTree() } + .onChange(of: store.userLists) { _, _ in treeNodes = rebuildTree() } + .onChange(of: store.listFolders) { _, _ in treeNodes = rebuildTree() } } } + @ViewBuilder + private var listContent: some View { + if !searchText.isEmpty { + searchResultsList + } else if store.listsLoading && treeNodes.isEmpty { + ListSkeletonView() + } else if let error = store.listsError, treeNodes.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await store.refreshLists() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if treeNodes.isEmpty { + ContentUnavailableView { + Label("No Lists", systemImage: "list.bullet.rectangle") + } description: { + Text("No lists found.") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(treeNodes) { node in + ListTreeNodeRow( + node: node, + onDeleteList: { list in Task { await deleteList(list) } }, + onDeleteFolder: { folder in Task { await deleteFolder(folder) } }, + onUpdateList: { _ in Task { await store.refreshLists() } } + ) + } + } + .listStyle(.sidebar) + } + } + + @ViewBuilder + private var addMenu: some View { + Menu { + Button { showCreateList = true } label: { + Label("New List", systemImage: "plus.rectangle") + } + if canCreateFolders { + Button { showCreateFolder = true } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) + } + .accessibilityLabel("New item") + .buttonStyle(.plain) + } + @ViewBuilder private var searchResultsList: some View { if isSearching { @@ -493,15 +503,6 @@ struct ListDetailView: View { ) } } - Section { - Button { - showAddItem = true - } label: { - Label("Add Item", systemImage: "plus") - } - .disabled(schema.isEmpty) - .accessibilityLabel("Add item to list") - } Section { if connections.isEmpty { Text("No connections yet") @@ -546,6 +547,15 @@ struct ListDetailView: View { .navigationTitle(list.name) .navigationBarTitleDisplayMode(.large) .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddItem = true + } label: { + Image(systemName: "plus") + } + .disabled(schema.isEmpty) + .accessibilityLabel("Add item to list") + } ToolbarItem(placement: .topBarTrailing) { Button { showWatchers = true diff --git a/InterlinedList/Views/PublicListDetailView.swift b/InterlinedList/Views/PublicListDetailView.swift index 95b57b7..fa746f4 100644 --- a/InterlinedList/Views/PublicListDetailView.swift +++ b/InterlinedList/Views/PublicListDetailView.swift @@ -124,7 +124,7 @@ struct PublicListDetailView: View { self.error = "Could not load this list." } // Watch status is best-effort and independent of the list body. - isWatching = try? await APIClient.shared.isWatchingList(listId: listId) + isWatching = try? await APIClient.shared.isWatchingList(listId: listId).watching } private func toggleWatch() async { @@ -137,7 +137,7 @@ struct PublicListDetailView: View { try await APIClient.shared.removeWatcher(listId: listId, userId: userId) isWatching = false } else { - _ = try await APIClient.shared.addWatcher(listId: listId, userId: userId, role: .watcher) + try await APIClient.shared.watchSelf(listId: listId) isWatching = true } } catch APIError.status(401) { diff --git a/InterlinedList/Views/UserProfileView.swift b/InterlinedList/Views/UserProfileView.swift index 685ea33..f116889 100644 --- a/InterlinedList/Views/UserProfileView.swift +++ b/InterlinedList/Views/UserProfileView.swift @@ -45,6 +45,8 @@ struct UserProfileView: View { VStack(spacing: 0) { if authState.user?.username != username { followHeader + } else { + organizationsSection } Picker("Content", selection: $selectedTab) { @@ -62,7 +64,6 @@ struct UserProfileView: View { } if authState.user?.username == username { - organizationsSection exportSection } } @@ -87,8 +88,14 @@ struct UserProfileView: View { } } label: { Image(systemName: "ellipsis.circle") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color(ILColor.primary)) + .frame(width: 34, height: 34) + .background(Color(ILColor.primary).opacity(0.12)) + .clipShape(Circle()) } .accessibilityLabel("More options") + .buttonStyle(.plain) } } } @@ -99,10 +106,10 @@ struct UserProfileView: View { } } .task { - await loadMessages() - if authState.user?.username == username && !organizationsLoaded { - await loadOrganizations() - } + let shouldLoadOrgs = authState.user?.username == username && !organizationsLoaded + async let msgs: Void = loadMessages() + async let orgs: Void = shouldLoadOrgs ? loadOrganizations() : () + _ = await (msgs, orgs) } .onChange(of: selectedTab) { _, tab in if tab == 1 && lists.isEmpty && listsError == nil { diff --git a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift index 00bae55..7135638 100644 --- a/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientGapPhasesTests.swift @@ -63,10 +63,11 @@ final class APIClientGapPhasesTests: XCTestCase { } func test_isWatchingList_decodesBool() async throws { - session.stub(json: #"{"watching":true}"#) - let watching = try await sut.isWatchingList(listId: "l1") + session.stub(json: #"{"watching":true,"role":"watcher"}"#) + let response = try await sut.isWatchingList(listId: "l1") XCTAssertTrue(session.lastRequest?.url?.path.hasSuffix("/api/lists/l1/watchers/me") == true) - XCTAssertTrue(watching) + XCTAssertTrue(response.watching) + XCTAssertEqual(response.role, "watcher") } func test_addWatcher_postsUserIdAndRole() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientModerationTests.swift b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift index 9b00ccb..0ebf908 100644 --- a/InterlinedListTests/APIClientTests/APIClientModerationTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift @@ -54,6 +54,30 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/report") } + func test_reportUser_sendsBearerToken() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportUser(id: "user-id", reason: .spam, detail: nil) + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_reportUser_bodyContainsReason() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportUser(id: "user-id", reason: .misinformation, detail: "test detail") + let bodyData = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + XCTAssertEqual(json?["reason"] as? String, "misinformation") + } + + func test_reportUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.reportUser(id: "user-id", reason: .spam, detail: nil) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: blockUser() func test_blockUser_sendsPostToCorrectPath() async throws { @@ -68,6 +92,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.httpMethod, "POST") } + func test_blockUser_sendsBearerToken() async throws { + session.stub(json: #"{"blocked":true}"#) + try await sut.blockUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_blockUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.blockUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: unblockUser() func test_unblockUser_sendsDeleteToCorrectPath() async throws { @@ -77,6 +117,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/block") } + func test_unblockUser_sendsBearerToken() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unblockUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_unblockUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.unblockUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: blockedUsers() func test_blockedUsers_sendsGetRequest() async throws { @@ -103,6 +159,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/mute") } + func test_muteUser_sendsBearerToken() async throws { + session.stub(json: #"{"muted":true}"#) + try await sut.muteUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_muteUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.muteUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: unmuteUser() func test_unmuteUser_sendsDeleteRequest() async throws { @@ -112,6 +184,22 @@ final class APIClientModerationTests: XCTestCase { XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/mute") } + func test_unmuteUser_sendsBearerToken() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unmuteUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_unmuteUser_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.unmuteUser(id: "user-id") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + // MARK: mutedUsers() func test_mutedUsers_decodesResponse() async throws { diff --git a/InterlinedListTests/ModelTests/ModerationModelTests.swift b/InterlinedListTests/ModelTests/ModerationModelTests.swift new file mode 100644 index 0000000..9ccca8d --- /dev/null +++ b/InterlinedListTests/ModelTests/ModerationModelTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import InterlinedList + +final class ModerationModelTests: XCTestCase { + private let decoder = JSONDecoder() + + // MARK: - BlockedUser + + func test_blockedUser_decodes_fullObject() throws { + let json = #"{"id":"u1","username":"alice","displayName":"Alice Smith","avatar":"https://example.com/a.jpg"}"# + let user = try decoder.decode(BlockedUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.id, "u1") + XCTAssertEqual(user.username, "alice") + XCTAssertEqual(user.displayName, "Alice Smith") + XCTAssertEqual(user.avatar, "https://example.com/a.jpg") + } + + func test_blockedUser_decodes_nullOptionalFields() throws { + let json = #"{"id":"u2","username":"bob","displayName":null,"avatar":null}"# + let user = try decoder.decode(BlockedUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.id, "u2") + XCTAssertEqual(user.username, "bob") + XCTAssertNil(user.displayName) + XCTAssertNil(user.avatar) + } + + // MARK: - BlockedUsersResponse + + func test_blockedUsersResponse_decodes_emptyList() throws { + let json = #"{"blockedUsers":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"# + let resp = try decoder.decode(BlockedUsersResponse.self, from: Data(json.utf8)) + XCTAssertTrue(resp.blockedUsers.isEmpty) + XCTAssertEqual(resp.pagination?.total, 0) + XCTAssertEqual(resp.pagination?.hasMore, false) + } + + func test_blockedUsersResponse_decodes_multipleUsers() throws { + let json = #""" + { + "blockedUsers": [ + {"id":"u1","username":"alice","displayName":"Alice","avatar":null}, + {"id":"u2","username":"bob","displayName":null,"avatar":null} + ], + "pagination": {"total":2,"limit":20,"offset":0,"hasMore":false} + } + """# + let resp = try decoder.decode(BlockedUsersResponse.self, from: Data(json.utf8)) + XCTAssertEqual(resp.blockedUsers.count, 2) + XCTAssertEqual(resp.blockedUsers[0].id, "u1") + XCTAssertEqual(resp.blockedUsers[1].username, "bob") + } + + func test_blockedUsersResponse_decodes_nullPagination() throws { + let json = #"{"blockedUsers":[],"pagination":null}"# + let resp = try decoder.decode(BlockedUsersResponse.self, from: Data(json.utf8)) + XCTAssertNil(resp.pagination) + } + + // MARK: - MutedUser + + func test_mutedUser_decodes_fullObject() throws { + let json = #"{"id":"u3","username":"carol","displayName":"Carol","avatar":"https://example.com/c.jpg"}"# + let user = try decoder.decode(MutedUser.self, from: Data(json.utf8)) + XCTAssertEqual(user.id, "u3") + XCTAssertEqual(user.username, "carol") + XCTAssertEqual(user.displayName, "Carol") + XCTAssertEqual(user.avatar, "https://example.com/c.jpg") + } + + func test_mutedUser_decodes_nullOptionalFields() throws { + let json = #"{"id":"u4","username":"dave","displayName":null,"avatar":null}"# + let user = try decoder.decode(MutedUser.self, from: Data(json.utf8)) + XCTAssertNil(user.displayName) + XCTAssertNil(user.avatar) + } + + // MARK: - MutedUsersResponse + + func test_mutedUsersResponse_decodes_emptyList() throws { + let json = #"{"mutedUsers":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"# + let resp = try decoder.decode(MutedUsersResponse.self, from: Data(json.utf8)) + XCTAssertTrue(resp.mutedUsers.isEmpty) + XCTAssertEqual(resp.pagination?.total, 0) + } + + func test_mutedUsersResponse_decodes_multipleUsers() throws { + let json = #""" + { + "mutedUsers": [ + {"id":"u5","username":"eve","displayName":"Eve","avatar":null} + ], + "pagination": {"total":1,"limit":20,"offset":0,"hasMore":false} + } + """# + let resp = try decoder.decode(MutedUsersResponse.self, from: Data(json.utf8)) + XCTAssertEqual(resp.mutedUsers.count, 1) + XCTAssertEqual(resp.mutedUsers[0].displayName, "Eve") + } +} diff --git a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift index 731ea35..63c41dc 100644 --- a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift +++ b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift @@ -1,3 +1,4 @@ +import Combine import XCTest @testable import InterlinedList @@ -37,6 +38,50 @@ final class AppDataStoreTests: XCTestCase { XCTAssertTrue(sut.feedMessages.contains { $0.id == "new" }) } + func test_insertFeedMessage_isVisibleAfterInitialMessages() { + sut.insertFeedMessage(makeMessage(id: "existing1")) + sut.insertFeedMessage(makeMessage(id: "existing2")) + sut.insertFeedMessage(makeMessage(id: "existing3")) + sut.insertFeedMessage(makeMessage(id: "newPost")) + XCTAssertEqual(sut.feedMessages.first?.id, "newPost") + XCTAssertTrue(sut.feedMessages.contains { $0.id == "existing1" }) + XCTAssertTrue(sut.feedMessages.contains { $0.id == "existing2" }) + XCTAssertTrue(sut.feedMessages.contains { $0.id == "existing3" }) + XCTAssertEqual(sut.feedMessages.count, 4) + } + + func test_insertFeedMessage_newMessageAppearsBeforeExisting() { + sut.insertFeedMessage(makeMessage(id: "first")) + sut.insertFeedMessage(makeMessage(id: "second")) + sut.insertFeedMessage(makeMessage(id: "third")) + XCTAssertEqual(sut.feedMessages[0].id, "third") + XCTAssertEqual(sut.feedMessages[1].id, "second") + XCTAssertEqual(sut.feedMessages[2].id, "first") + } + + func test_insertFeedMessage_doesNotDuplicateExistingMessage() { + sut.insertFeedMessage(makeMessage(id: "dup")) + sut.insertFeedMessage(makeMessage(id: "dup")) + // insertFeedMessage does not deduplicate at the store layer. + // FeedView's onChange filters by !existingIds.contains($0.id) before prepending. + XCTAssertEqual(sut.feedMessages.count, 2) + } + + func test_insertFeedMessage_publishesCountChange() { + let countIncreased = expectation(description: "feedMessages count increases") + var cancellables = Set() + sut.$feedMessages + .dropFirst() + .sink { messages in + if messages.count == 1 { + countIncreased.fulfill() + } + } + .store(in: &cancellables) + sut.insertFeedMessage(makeMessage(id: "z")) + wait(for: [countIncreased], timeout: 1.0) + } + // MARK: - reset func test_reset_clearsFeedMessages() { diff --git a/README.md b/README.md index 7458ca3..eee4069 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # InterlinedList iOS +[![iOS Build](https://github.com/CompositeCode/interlinedlist-ios/actions/workflows/ios.yml/badge.svg)](https://github.com/CompositeCode/interlinedlist-ios/actions/workflows/ios.yml) +[![iOS 17+](https://img.shields.io/badge/iOS-17%2B-blue?logo=apple)](https://developer.apple.com/ios/) +[![Swift 5.9+](https://img.shields.io/badge/Swift-5.9%2B-orange?logo=swift)](https://swift.org) +[![Xcode 15+](https://img.shields.io/badge/Xcode-15%2B-147EFB?logo=xcode&logoColor=white)](https://developer.apple.com/xcode/) +[![No dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)](https://github.com/CompositeCode/interlinedlist-ios) + Native iOS app for [InterlinedList](https://interlinedlist.com) — a social list-sharing service. Sign in, browse a message feed, post and reply, and manage nested lists, documents, organizations, and your social graph. Built in SwiftUI with no third-party dependencies. ## Requirements diff --git a/blocker-prompts.md b/blocker-prompts.md index a61eaff..18b59f6 100644 --- a/blocker-prompts.md +++ b/blocker-prompts.md @@ -14,9 +14,18 @@ the API openapi.json and `/help/api/*` pages for current route definitions. --- +## Audit log + +| Date | Summary | +|---|---| +| 2026-07-02 | Initial audit. All items below identified as outstanding. | +| 2026-07-07 | Re-audit. All hard blockers and v1 targets confirmed resolved. One new Bearer-auth gap found on `GET /api/user/organizations`. See Prompt 8 (updated). | + +--- + ## HARD BLOCKERS -### 1. Implement content reporting endpoints +### 1. Implement content reporting endpoints ✅ RESOLVED 2026-07-07 **Why:** Apple Guideline 1.2 requires every UGC/social app to let users report objectionable content. The iOS app **will be rejected** without this. @@ -54,7 +63,7 @@ Response: { "reported": true } --- -### 2. Implement user block / unblock endpoints +### 2. Implement user block / unblock endpoints ✅ RESOLVED 2026-07-07 **Why:** Apple Guideline 1.2 requires a mechanism to block abusive users. Hard gate on App Store submission. @@ -98,7 +107,7 @@ Response 200: --- -### 3. Implement user mute endpoint (recommended, not strictly required) +### 3. Implement user mute endpoint (recommended, not strictly required) ✅ RESOLVED 2026-07-07 **Why:** A mute (softer than a block) is not strictly required by Apple 1.2, but if a server-side mute exists it prevents re-surfacing muted content after @@ -120,7 +129,7 @@ iOS client will fall back to local-only muting. --- -### 4. Publish a Community Guidelines / EULA page +### 4. Publish a Community Guidelines / EULA page ✅ RESOLVED 2026-07-07 **Why:** Apple 1.2 requires UGC apps to display a zero-tolerance community agreement that users accept at registration. The iOS `RegisterView` needs a @@ -146,7 +155,7 @@ EULA decision) to the iOS team so they can wire `RegisterView`. ## v1 TARGETS -### 5. Confirm and document push notification endpoint contracts +### 5. Confirm and document push notification endpoint contracts ✅ RESOLVED 2026-07-07 **Why:** Phase 9 (APNs push notifications) ships in v1. The endpoints reportedly exist (`POST /api/push/register`, `DELETE /api/push/unregister`) @@ -189,12 +198,16 @@ Expected response: { "unregistered": true } --- -### 6. Confirm or add org-author field to create-message endpoint +### 6. Confirm or add org-author field to create-message endpoint ✅ CONFIRMED 2026-07-07 **Why:** Phase 15 (post on behalf of an organization) requires the create-message endpoint to accept the posting organization as the author. This field has not been confirmed to exist. +**Confirmed 2026-07-07:** `organizationId` is recognized — a probe with a non-member org +UUID returned `403 Forbidden: you must be an owner or admin of this organization`, confirming +the field is parsed and the auth check is enforced. No further backend work needed here. + **Confirm or add:** `POST /api/messages` (camelCase body): @@ -217,7 +230,7 @@ team can formally defer Phase 15. --- -### 7. Make GET /api/user/identities accept Bearer tokens +### 7. Make GET /api/user/identities accept Bearer tokens ✅ RESOLVED 2026-07-07 **Why:** This endpoint currently returns `401 Unauthorized` for a valid Bearer token (confirmed 2026-07-02). It accepts session cookies only. Because @@ -263,16 +276,34 @@ there eliminates the extra round-trip. These are not App Store blockers but they each require a server change and are worth fixing before v1 if the effort is low. -### 8. Add userRole and memberCount to org list responses +### 8. Make GET /api/user/organizations accept Bearer tokens ⚠️ ACTIVE — found 2026-07-07 + +**Why:** `GET /api/user/organizations` returns `{"error":"Unauthorized"}` when called with a +valid Bearer token. Session-cookie auth works fine. Because the iOS app is Bearer-only, the +org list in the app is permanently empty — the same class of bug that was just fixed for +`GET /api/user/identities` (Prompt 7). -**Why:** `GET /api/user/organizations` and `GET /api/organizations` don't -include `userRole`, so `OrganizationDetailView` must make a second round-trip -to `GET /api/organizations/{id}` just to learn whether the caller is -owner/admin/member and therefore whether to show edit/delete actions. +**Confirmed 2026-07-07:** When called with a session cookie the endpoint already returns +`userRole` and `memberCount` per org — so the response shape (the original ask) is already +correct; only the auth layer needs fixing. -**Fix:** Add per-item fields to both list endpoints: +**Fix:** Accept a valid Bearer token on `GET /api/user/organizations`, the same way +`GET /api/user` and (now) `GET /api/user/identities` do. + +Confirmed response shape (no change needed): ```json -{ "id": "…", "name": "…", "userRole": "owner" | "admin" | "member", "memberCount": 12, … } +{ + "organizations": [ + { + "id": "…", + "name": "…", + "slug": "…", + "userRole": "owner" | "admin" | "member", + "memberCount": 3, + … + } + ] +} ``` --- @@ -449,17 +480,20 @@ Conflicted docs should be returned so the iOS client can show a banner. ## Confirmed gaps for reference -These were live issues as of the last audit (2026-07-02): +Last audit: 2026-07-07. | Ref | Gap | iOS workaround | Status | |---|---|---|---| -| D0 | `GET /api/user/identities` returns 401 for Bearer tokens | Mastodon picker always empty | **Active — see Prompt 7** | +| D0 | `GET /api/user/identities` returns 401 for Bearer tokens | Mastodon picker always empty | ✅ Resolved 2026-07-07 | +| D0b | `GET /api/user/organizations` returns 401 for Bearer tokens | Org list always empty | **Active — see Prompt 8** | | B4 | `/api/github/*` requires session cookie, not Bearer | GitHub integration deferred | Deferred — Prompt 15 | | B6 | No tag discovery endpoints | Tag explorer/autocomplete deferred | Deferred — Prompt 16 | | B8 | No realtime (WebSocket/SSE) | Poll-only | Deferred — Prompt 17 | -| B10 | Moderation endpoints unverified | Phase 14 blocked | **Hard blocker — Prompts 1–4** | -| C2 | Watcher list pagination has `total` at top level, not in `pagination` | Special-cased decoder | Quality — Prompt 9 | -| C3 | Self-watch `userId` requirement unclear | iOS sends own userId | Quality — Prompt 13 | -| D1 | Cross-post failure shape unconfirmed | Toast shows only successes | Quality — Prompt 14 | +| B10 | Moderation (report/block/mute) endpoints missing | Phase 14 blocked | ✅ Resolved 2026-07-07 | +| B10b | Community guidelines page missing | RegisterView terms link broken | ✅ Resolved 2026-07-07 | +| B11 | Push register/unregister contracts unconfirmed | PushService not built | ✅ Resolved 2026-07-07 | +| C2 | Watcher list pagination has `total` at top level, not in `pagination` | Special-cased decoder | Quality — Prompt 9 (unverified) | +| C3 | Self-watch `userId` requirement unclear | iOS sends own userId | Quality — Prompt 13 (unverified) | +| D1 | Cross-post failure shape unconfirmed | Toast shows only successes | Quality — Prompt 14 (unverified) | | D2 | `linkedInTargets[].kind` vocabulary undocumented | Boolean-only cross-post | Deferred with Phase 18 | -| E | Avatar endpoints don't return updated user | Extra `GET /api/user` after upload | Quality — Prompt 11 | +| E | Avatar endpoints don't return updated user | Extra `GET /api/user` after upload | Quality — Prompt 11 (unverified) |