diff --git a/App-Store-Deployment.md b/App-Store-Deployment.md index f111801..a6d837c 100644 --- a/App-Store-Deployment.md +++ b/App-Store-Deployment.md @@ -45,7 +45,9 @@ The following phases are complete and in the current build: **What works today:** auth (email + OAuth ×5), feed (infinite scroll, dig, reply, search, link previews), compose (text/image/video, cross-post, repost, scheduled), lists (CRUD, folders, schema editor, watchers), documents (CRUD, folders, search, public reader), public browse, notifications (tray, preferences), profile, follow, organizations, settings, exports. -**Not yet built (remaining phases):** push notifications, inline document image upload, UGC moderation (report/block/mute/terms gate), post as org, offline document sync, realtime, GitHub integration, tag discovery. +**Not yet built (remaining phases):** inline document image upload, post as org, offline document sync, realtime, GitHub integration, tag discovery. + +**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). --- @@ -75,17 +77,17 @@ If the endpoints don't exist, this is **blocked on backend** — escalate immediately, as it is the true critical-path blocker. iOS work (after backend is confirmed): -- [ ] `Menu` overflow on every message row in `FeedView`, `MessageThreadView`, public views → "Report…" action → `ReportSheet` (reason picker + optional detail) → POST → toast -- [ ] "Report @user" on `UserProfileView` -- [ ] "Block @user" on message overflow and `UserProfileView`; optimistic local filter on feed -- [ ] `BlockedUsersView` reachable from `SettingsView`; unblock action -- [ ] Mute (local-only if no server endpoint) -- [ ] Terms/community-guidelines acceptance checkbox on `RegisterView` (blocks submit until checked); link to a live URL (confirm below in §2) -- [ ] Surface Terms + Guidelines links in `SettingsView` → About -- [ ] New files: `Views/ReportSheet.swift`, `Views/BlockedUsersView.swift`, `Models/Moderation.swift` -- [ ] New `APIClient` methods: `reportMessage`, `reportUser`, `blockUser`, `unblockUser`, `blockedUsers` (and mute variants if supported) +- [x] `Menu` overflow on every message row in `FeedView`, `MessageThreadView` → "Report…" action → `ReportSheet` (reason picker + optional detail) → POST → toast +- [x] "Report @user" on `UserProfileView` +- [x] "Block @user" on message overflow and `UserProfileView`; optimistic local filter on feed +- [x] `BlockedUsersView` reachable from `SettingsView`; unblock action +- [x] Mute (server-backed via `/api/users/{id}/mute` — APIClient methods added) +- [x] Terms/community-guidelines acceptance checkbox on `RegisterView` (blocks submit until checked); links to `/terms` +- [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 -- [ ] `#Preview` for all new views; `.accessibilityLabel` on all new controls +- [x] `#Preview` for all new views; `.accessibilityLabel` on all new controls #### Phase 0.5 — Info.plist Hygiene `Tiny` ⛔ One-file PR; do this in parallel with Phase 14. @@ -403,9 +405,9 @@ Smoke-test checklist on a real device before submitting for App Store review: Copy this and tick it off just before submitting. **Feature gates** -- [ ] Phase 14 — UGC safety shipped (report, block, mute, terms gate) -- [ ] Phase 0.5 — Info.plist hygiene done (`ITSAppUsesNonExemptEncryption`, `arm64`, icon) -- [ ] Phase 9 — Push notifications wired (APNs capability, PushService, register/unregister) +- [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 **Accounts & credentials** - [ ] Apple Developer Program membership active; agreements accepted in ASC diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 40b9a79..a6fa370 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -8,9 +8,13 @@ /* Begin PBXBuildFile section */ 0C2A6EE423332702FD29DEBA /* Manrope.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 229B4152945DABCD6D88A59A /* Manrope.ttf */; }; + 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */; }; 1775C9B4C2CF08267AF3B138 /* WatchersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5828367E38948582EF1DF2A /* WatchersListView.swift */; }; + 17CD6CF35900DC95AA284E3C /* PushService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B79D26B822DCA1188DF37E9 /* PushService.swift */; }; 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */; }; + 47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1147B978D9E72695E4457454 /* Moderation.swift */; }; 5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; }; + 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; }; 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */; }; 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; }; @@ -56,6 +60,8 @@ B1C1D1E1F1A1000B /* DocumentSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */; }; B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000E /* ListItemFormView.swift */; }; C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; }; + C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7800699C927CE3E133944E62 /* ReportSheet.swift */; }; + CB45081F767583424D54523C /* APIClientPushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */; }; CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */; }; D0A1D0A1D0A10001 /* OAuthCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A10002 /* OAuthCoordinator.swift */; }; D0A1D0A1D0A10003 /* ChangeEmailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A10004 /* ChangeEmailView.swift */; }; @@ -119,13 +125,18 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientPushTests.swift; sourceTree = ""; }; 0EF29F5DC946D80A63000209 /* FollowListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowListView.swift; sourceTree = ""; }; - 229B4152945DABCD6D88A59A /* Manrope.ttf */ = {isa = PBXFileReference; includeInIndex = 1; path = InterlinedList/Fonts/Manrope.ttf; sourceTree = SOURCE_ROOT; }; + 1147B978D9E72695E4457454 /* Moderation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Moderation.swift; sourceTree = ""; }; + 229B4152945DABCD6D88A59A /* Manrope.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/Manrope.ttf; sourceTree = SOURCE_ROOT; }; 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = ""; }; 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppDataStoreTests.swift; path = AppDataStoreTests.swift; sourceTree = ""; }; + 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDataStoreTests.swift; sourceTree = ""; }; + 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientModerationTests.swift; sourceTree = ""; }; 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicBrowse.swift; sourceTree = ""; }; - 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */ = {isa = PBXFileReference; includeInIndex = 1; path = InterlinedList/Fonts/SpaceGrotesk.ttf; sourceTree = SOURCE_ROOT; }; + 7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = ""; }; + 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/SpaceGrotesk.ttf; sourceTree = SOURCE_ROOT; }; + 8B79D26B822DCA1188DF37E9 /* PushService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushService.swift; sourceTree = ""; }; 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = ""; }; 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = ""; }; A1B1C1D1E1F10000 /* InterlinedList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InterlinedList.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -160,7 +171,7 @@ A3C3D3E3F3A30052 /* Document.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Document.swift; sourceTree = ""; }; A3C3D3E3F3A30054 /* DocumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsView.swift; sourceTree = ""; }; A3C3D3E3F3A30056 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; - A79A316417B99AB22D2FFDC1 /* JetBrainsMono.ttf */ = {isa = PBXFileReference; includeInIndex = 1; path = InterlinedList/Fonts/JetBrainsMono.ttf; sourceTree = SOURCE_ROOT; }; + A79A316417B99AB22D2FFDC1 /* JetBrainsMono.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/JetBrainsMono.ttf; sourceTree = SOURCE_ROOT; }; B1C1D1E1F1A10002 /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; B1C1D1E1F1A10004 /* AppDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDataStore.swift; sourceTree = ""; }; B1C1D1E1F1A10006 /* SkeletonBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonBlock.swift; sourceTree = ""; }; @@ -178,10 +189,12 @@ D0A1D0A1D0A1000A /* EmailVerificationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailVerificationBanner.swift; sourceTree = ""; }; D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentitiesView.swift; sourceTree = ""; }; D0A1D0A1D0A1000E /* OAuthSignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSignInButton.swift; sourceTree = ""; }; + E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BlockedUsersView.swift; sourceTree = ""; }; E5828367E38948582EF1DF2A /* WatchersListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchersListView.swift; sourceTree = ""; }; ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; F0470C00F1A0E215224120A1 /* ListWatcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListWatcher.swift; sourceTree = ""; }; F379C02B2FCEB9440069B81C /* InterlinedList.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = InterlinedList.xctestplan; sourceTree = ""; }; + F3E6216F2FFB4666000D153A /* InterlinedList.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InterlinedList.entitlements; sourceTree = ""; }; FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; }; T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; T1E5T1E5T1E50002 /* InterlinedListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListTests.swift; sourceTree = ""; }; @@ -249,7 +262,6 @@ 229B4152945DABCD6D88A59A /* Manrope.ttf */, A79A316417B99AB22D2FFDC1 /* JetBrainsMono.ttf */, ); - name = Fonts; path = Fonts; sourceTree = ""; }; @@ -266,6 +278,7 @@ A1B1C1D1E1F1001D /* InterlinedList */ = { isa = PBXGroup; children = ( + F3E6216F2FFB4666000D153A /* InterlinedList.entitlements */, A1B1C1D1E1F10002 /* InterlinedListApp.swift */, A1B1C1D1E1F1001F /* Models */, A1B1C1D1E1F10020 /* Services */, @@ -300,6 +313,7 @@ F0470C00F1A0E215224120A1 /* ListWatcher.swift */, ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */, 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */, + 1147B978D9E72695E4457454 /* Moderation.swift */, ); path = Models; sourceTree = ""; @@ -315,6 +329,7 @@ B1C1D1E1F1A10004 /* AppDataStore.swift */, D0A1D0A1D0A10002 /* OAuthCoordinator.swift */, FC5914D730B4355F02E89D81 /* ILColor.swift */, + 8B79D26B822DCA1188DF37E9 /* PushService.swift */, ); path = Services; sourceTree = ""; @@ -356,6 +371,8 @@ E5828367E38948582EF1DF2A /* WatchersListView.swift */, C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */, 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */, + 7800699C927CE3E133944E62 /* ReportSheet.swift */, + E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */, ); path = Views; sourceTree = ""; @@ -400,6 +417,8 @@ T1E5T1E5T1E5P022 /* APIClientOrganizationsTests.swift */, T1E5T1E5T1E5P024 /* APIClientDeleteAccountTests.swift */, B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */, + 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */, + 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -585,6 +604,10 @@ CE93B45A2285EF8FD995D9FE /* OrganizationsView.swift in Sources */, D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */, 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */, + 47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */, + C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */, + 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */, + 17CD6CF35900DC95AA284E3C /* PushService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -633,6 +656,8 @@ 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */, 6C10CC420377B0E8AE5C82DB /* GapModelsTests.swift in Sources */, E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */, + 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */, + CB45081F767583424D54523C /* APIClientPushTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -756,6 +781,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = InterlinedList/InterlinedList.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; @@ -786,6 +812,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = InterlinedList/InterlinedList.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; diff --git a/InterlinedList/Info.plist b/InterlinedList/Info.plist index 621a86d..1ac0acc 100644 --- a/InterlinedList/Info.plist +++ b/InterlinedList/Info.plist @@ -29,10 +29,11 @@ UILaunchScreen + ITSAppUsesNonExemptEncryption + UIRequiredDeviceCapabilities - armv7 - network + arm64 UISupportedInterfaceOrientations diff --git a/InterlinedList/InterlinedList.entitlements b/InterlinedList/InterlinedList.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/InterlinedList/InterlinedList.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/InterlinedList/InterlinedListApp.swift b/InterlinedList/InterlinedListApp.swift index 71cbf4f..8217797 100644 --- a/InterlinedList/InterlinedListApp.swift +++ b/InterlinedList/InterlinedListApp.swift @@ -5,9 +5,11 @@ import SwiftUI import UIKit +import UserNotifications @main struct InterlinedListApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var authState = AuthState() @StateObject private var store = AppDataStore() @StateObject private var router = AppRouter() @@ -23,7 +25,12 @@ struct InterlinedListApp: App { .environmentObject(store) .environmentObject(router) .onChange(of: authState.hasToken) { _, has in - if !has { store.reset() } + if has { + PushService.shared.requestPermissionAndRegister() + } else { + PushService.shared.unregister() + store.reset() + } } .onOpenURL { url in handleDeepLink(url) @@ -129,3 +136,52 @@ enum AppDeepLink: Identifiable, Hashable { final class AppRouter: ObservableObject { @Published var pendingDeepLink: AppDeepLink? } + +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { @MainActor in + PushService.shared.didRegister(deviceToken: deviceToken) + } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + Task { @MainActor in + PushService.shared.didFailToRegister(error: error) + } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + Task { @MainActor in + PushService.shared.handleForegroundNotification(notification, completionHandler: completionHandler) + } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + Task { @MainActor in + PushService.shared.handleNotificationResponse(response) + completionHandler() + } + } + + func applicationDidBecomeActive(_ application: UIApplication) { + Task { @MainActor in + PushService.shared.clearBadge() + } + } +} diff --git a/InterlinedList/Models/Message.swift b/InterlinedList/Models/Message.swift index ae4dc60..e404510 100644 --- a/InterlinedList/Models/Message.swift +++ b/InterlinedList/Models/Message.swift @@ -156,6 +156,7 @@ struct CreateMessageBody: Encodable { var linkedInLinkAsFirstComment: Bool? var crossPostToTwitter: Bool? var scheduledCrossPostConfig: ScheduledCrossPostConfig? + var organizationId: String? } /// One platform's result after a cross-post attempt. Surfaced in a post-publish toast. diff --git a/InterlinedList/Models/Moderation.swift b/InterlinedList/Models/Moderation.swift new file mode 100644 index 0000000..6e1ccc8 --- /dev/null +++ b/InterlinedList/Models/Moderation.swift @@ -0,0 +1,50 @@ +// +// Moderation.swift +// InterlinedList +// + +import Foundation + +enum ReportReason: String, CaseIterable, Identifiable { + case spam + case harassment + case misinformation + case inappropriate + case other + + var id: String { rawValue } + + var displayName: String { + switch self { + case .spam: return "Spam" + case .harassment: return "Harassment" + case .misinformation: return "Misinformation" + case .inappropriate: return "Inappropriate content" + case .other: return "Other" + } + } +} + +struct BlockedUser: Identifiable, Decodable { + let id: String + let username: String + let displayName: String? + let avatar: String? +} + +struct BlockedUsersResponse: Decodable { + let blockedUsers: [BlockedUser] + let pagination: Pagination? +} + +struct MutedUser: Identifiable, Decodable { + let id: String + let username: String + let displayName: String? + let avatar: String? +} + +struct MutedUsersResponse: Decodable { + let mutedUsers: [MutedUser] + let pagination: Pagination? +} diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index e13be09..6d6377c 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -275,7 +275,8 @@ final class APIClient { crossPostToLinkedIn: Bool? = nil, linkedInTargets: [LinkedInTarget]? = nil, linkedInLinkAsFirstComment: Bool? = nil, - crossPostToTwitter: Bool? = nil + crossPostToTwitter: Bool? = nil, + organizationId: String? = nil ) async throws -> PostMessageResult { let body = CreateMessageBody( content: content, publiclyVisible: publiclyVisible, parentId: parentId, @@ -283,7 +284,8 @@ final class APIClient { pushedMessageId: pushedMessageId, mastodonProviderIds: mastodonProviderIds, crossPostToBluesky: crossPostToBluesky, crossPostToLinkedIn: crossPostToLinkedIn, linkedInTargets: linkedInTargets, linkedInLinkAsFirstComment: linkedInLinkAsFirstComment, - crossPostToTwitter: crossPostToTwitter, scheduledCrossPostConfig: nil) + crossPostToTwitter: crossPostToTwitter, scheduledCrossPostConfig: nil, + organizationId: organizationId) // Backend expects camelCase (publiclyVisible, parentId); snake_case would send publicly_visible and be ignored. guard let url = URL(string: baseURL + "/api/messages") else { throw APIError.invalidURL } var request = URLRequest(url: url) @@ -632,6 +634,30 @@ final class APIClient { return try decoder.decode(UploadResponse.self, from: responseData).url } + // MARK: - Document image upload + + func uploadDocumentImage(documentId: String, data: Data, mimeType: String) async throws -> String { + let encoded = documentId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? documentId + guard let url = URL(string: baseURL + "/api/documents/\(encoded)/images/upload") else { throw APIError.invalidURL } + let boundary = UUID().uuidString + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let ext = mimeType == "image/png" ? "png" : "jpg" + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"image\"; filename=\"upload.\(ext)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + let (responseData, response) = try await session.data(for: request) + try checkResponse(data: responseData, response: response) + struct UploadResponse: Decodable { let url: String } + return try decoder.decode(UploadResponse.self, from: responseData).url + } + // MARK: - Video upload func uploadVideo(data: Data, mimeType: String) async throws -> String { @@ -1052,6 +1078,87 @@ final class APIClient { return (response.messages, response.pagination) } + // MARK: - Moderation — report, block, mute + + func reportMessage(id: String, reason: ReportReason, detail: String?) async throws { + struct Body: Encodable { let reason: String; let detail: String? } + struct Response: Decodable { let reported: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await postCamel("/api/messages/\(encoded)/report", body: Body(reason: reason.rawValue, detail: detail)) + } + + func reportUser(id: String, reason: ReportReason, detail: String?) async throws { + struct Body: Encodable { let reason: String; let detail: String? } + struct Response: Decodable { let reported: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await postCamel("/api/users/\(encoded)/report", body: Body(reason: reason.rawValue, detail: detail)) + } + + func blockUser(id: String) async throws { + struct Empty: Encodable {} + struct Response: Decodable { let blocked: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await postCamel("/api/users/\(encoded)/block", body: Empty()) + } + + func unblockUser(id: String) async throws { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + guard let url = URL(string: baseURL + "/api/users/\(encoded)/block") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + + func blockedUsers(limit: Int = 50, offset: Int = 0) async throws -> BlockedUsersResponse { + return try await get("/api/user/blocks?limit=\(limit)&offset=\(offset)") + } + + func muteUser(id: String) async throws { + struct Empty: Encodable {} + struct Response: Decodable { let muted: Bool? } + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + let _: Response = try await postCamel("/api/users/\(encoded)/mute", body: Empty()) + } + + func unmuteUser(id: String) async throws { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + guard let url = URL(string: baseURL + "/api/users/\(encoded)/mute") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + + func mutedUsers(limit: Int = 50, offset: Int = 0) async throws -> MutedUsersResponse { + return try await get("/api/user/mutes?limit=\(limit)&offset=\(offset)") + } + + // MARK: - Push notifications (Phase 9) + + func registerPushDevice(token: String) async throws { + struct Body: Encodable { let token: String; let platform: String } + struct Response: Decodable { let registered: Bool? } + let _: Response = try await postCamel("/api/push/register", body: Body(token: token, platform: "ios")) + } + + func unregisterPushDevice(token: String) async throws { + struct Body: Encodable { let token: String } + guard let url = URL(string: baseURL + "/api/push/unregister") else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + request.httpBody = try camelCaseEncoder.encode(Body(token: token)) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + // MARK: - Private helpers private func getRawData(_ path: String) async throws -> Data { diff --git a/InterlinedList/Services/PushService.swift b/InterlinedList/Services/PushService.swift new file mode 100644 index 0000000..3a99697 --- /dev/null +++ b/InterlinedList/Services/PushService.swift @@ -0,0 +1,87 @@ +// +// PushService.swift +// InterlinedList +// + +import UIKit +import UserNotifications +import os.log + +private let pushLog = Logger(subsystem: "com.interlinedlist.app", category: "PushService") + +@MainActor +final class PushService: NSObject { + static let shared = PushService() + + private var registeredToken: String? + + private override init() { super.init() } + + func requestPermissionAndRegister() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in + if let error { + pushLog.error("Push permission error: \(error)") + return + } + guard granted else { + pushLog.info("Push permission denied") + return + } + Task { @MainActor in + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + func didRegister(deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + guard token != registeredToken else { return } + registeredToken = token + pushLog.info("APNs device token registered") + Task { + do { + try await APIClient.shared.registerPushDevice(token: token) + } catch { + pushLog.error("Failed to register push device: \(error)") + registeredToken = nil + } + } + } + + func didFailToRegister(error: Error) { + pushLog.error("APNs registration failed: \(error)") + } + + func unregister() { + guard let token = registeredToken else { return } + registeredToken = nil + UIApplication.shared.unregisterForRemoteNotifications() + Task { + do { + try await APIClient.shared.unregisterPushDevice(token: token) + } catch { + pushLog.error("Failed to unregister push device: \(error)") + } + } + } + + func clearBadge() { + UNUserNotificationCenter.current().setBadgeCount(0) { error in + if let error { pushLog.error("Badge clear error: \(error)") } + } + } + + func handleForegroundNotification( + _ notification: UNNotification, + completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } + + func handleNotificationResponse(_ response: UNNotificationResponse) { + let userInfo = response.notification.request.content.userInfo + guard let actionUrl = userInfo["actionUrl"] as? String, + let url = URL(string: actionUrl) else { return } + UIApplication.shared.open(url) + } +} diff --git a/InterlinedList/Views/BlockedUsersView.swift b/InterlinedList/Views/BlockedUsersView.swift new file mode 100644 index 0000000..be4a721 --- /dev/null +++ b/InterlinedList/Views/BlockedUsersView.swift @@ -0,0 +1,101 @@ +// +// BlockedUsersView.swift +// InterlinedList +// + +import SwiftUI + +struct BlockedUsersView: View { + @EnvironmentObject private var authState: AuthState + @State private var blockedUsers: [BlockedUser] = [] + @State private var isLoading = true + @State private var error: String? + @State private var actionError: String? + + var body: some View { + Group { + if isLoading && blockedUsers.isEmpty { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error, blockedUsers.isEmpty { + ContentUnavailableView { + Label("Unable to load", systemImage: "exclamationmark.triangle") + } description: { + Text(error) + } actions: { + Button("Retry") { Task { await load() } } + } + } else if blockedUsers.isEmpty { + ContentUnavailableView( + "No blocked users", + systemImage: "person.slash", + description: Text("Users you block will appear here.") + ) + } else { + List { + if let actionError { + Section { + Text(actionError).font(.ilMono()).foregroundStyle(.red) + } + } + ForEach(blockedUsers) { user in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("@\(user.username)") + .font(.ilBody(15)) + .fontWeight(.medium) + if let displayName = user.displayName, !displayName.isEmpty { + Text(displayName) + .font(.ilBody()) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("Unblock") { + Task { await unblock(user) } + } + .buttonStyle(.bordered) + .font(.ilMono()) + .accessibilityLabel("Unblock @\(user.username)") + } + } + } + } + } + .navigationTitle("Blocked Users") + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let response = try await APIClient.shared.blockedUsers() + blockedUsers = response.blockedUsers + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Could not load blocked users." + } + } + + private func unblock(_ user: BlockedUser) async { + actionError = nil + do { + try await APIClient.shared.unblockUser(id: user.id) + blockedUsers.removeAll { $0.id == user.id } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + actionError = "Could not unblock @\(user.username)." + } + } +} + +#Preview { + NavigationStack { + BlockedUsersView() + .environmentObject(AuthState()) + } +} diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index 2da23c3..d35d33f 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -44,6 +44,8 @@ struct ComposeView: View { /// Used for the post-publish confirmation when the deployment doesn't return the /// per-platform `crossPostResults` shape (which, in practice, it usually doesn't). @State private var lastCrossPostUrls: [CrossPostUrl] = [] + @State private var postableOrgs: [Organization] = [] + @State private var selectedOrgId: String? = nil private var isReply: Bool { replyTo != nil } private var isRepost: Bool { repostOf != nil } @@ -103,6 +105,18 @@ struct ComposeView: View { } } + if !isReply && !isRepost && !postableOrgs.isEmpty { + Section("Post as") { + Picker("Author", selection: $selectedOrgId) { + Text("Yourself").tag(String?.none) + ForEach(postableOrgs) { org in + Text(org.name).tag(Optional(org.id)) + } + } + .accessibilityLabel("Post author") + } + } + if showAdvancedBar && !isReply && canUseSubscriberFeatures { crossPostSection } @@ -145,6 +159,7 @@ struct ComposeView: View { } .task { await loadIdentitiesIfNeeded() + await loadPostableOrgs() } .alert(successTitle, isPresented: $showSuccess) { Button("OK") { @@ -161,6 +176,7 @@ struct ComposeView: View { crossPostLinkedIn = false crossPostTwitter = false selectedMastodonIds = [] + selectedOrgId = nil applyUserDefaults() } } message: { @@ -458,6 +474,15 @@ struct ComposeView: View { } } + private func loadPostableOrgs() async { + guard !isReply && !isRepost else { return } + let orgs = (try? await APIClient.shared.userOrganizations()) ?? [] + postableOrgs = orgs.filter { org in + guard let role = org.role else { return false } + return role == .owner || role == .admin + } + } + @ViewBuilder private func repostPreview(_ original: Message) -> some View { VStack(alignment: .leading, spacing: 4) { @@ -541,7 +566,8 @@ struct ComposeView: View { mastodonProviderIds: mastodonIds, crossPostToBluesky: crossPostEnabled && crossPostBluesky ? true : nil, crossPostToLinkedIn: crossPostEnabled && crossPostLinkedIn ? true : nil, - crossPostToTwitter: crossPostEnabled && crossPostTwitter ? true : nil + crossPostToTwitter: crossPostEnabled && crossPostTwitter ? true : nil, + organizationId: selectedOrgId ) lastCrossPostResults = result.crossPostResults lastCrossPostUrls = result.message.crossPostUrls ?? [] diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index 659c406..cfaf880 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import PhotosUI struct DocumentsView: View { @EnvironmentObject var authState: AuthState @@ -643,6 +644,9 @@ private struct EditDocumentView: View { @State private var availableFolders: [DocumentFolder] = [] @State private var isLoading = false @State private var errorMessage: String? + @State private var selectedPhoto: PhotosPickerItem? + @State private var isUploadingImage = false + @State private var imageUploadError: String? init(document: Document, onSave: @escaping (Document) -> Void) { self.document = document @@ -665,6 +669,17 @@ private struct EditDocumentView: View { .lineLimit(8...20) .font(.system(.body, design: .monospaced)) } + Section { + PhotosPicker(selection: $selectedPhoto, matching: .images) { + Label(isUploadingImage ? "Uploading…" : "Insert image…", systemImage: "photo.badge.plus") + .font(.ilBody()) + } + .disabled(isUploadingImage) + .accessibilityLabel("Insert image into document") + if let imageUploadError { + Text(imageUploadError).foregroundStyle(.red).font(.ilMono()) + } + } Section { Toggle("Public", isOn: $isPublic) } @@ -698,6 +713,10 @@ private struct EditDocumentView: View { availableFolders = folders } } + .onChange(of: selectedPhoto) { _, newItem in + guard let newItem else { return } + Task { await uploadPhoto(newItem) } + } } } @@ -724,6 +743,30 @@ private struct EditDocumentView: View { errorMessage = "Failed to save changes." } } + + private func uploadPhoto(_ item: PhotosPickerItem) async { + imageUploadError = nil + isUploadingImage = true + defer { isUploadingImage = false; selectedPhoto = nil } + guard let data = try? await item.loadTransferable(type: Data.self) else { + imageUploadError = "Failed to load image." + return + } + let mimeType = data.starts(with: [0x89, 0x50]) ? "image/png" : "image/jpeg" + do { + let url = try await APIClient.shared.uploadDocumentImage( + documentId: document.id, + data: data, + mimeType: mimeType + ) + let altText = "image" + content += "\n![\(altText)](\(url))" + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + imageUploadError = "Image upload failed." + } + } } private struct CreateDocumentFolderView: View { diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index 53b3102..dd56ca3 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -30,6 +30,8 @@ struct FeedView: View { @State private var searchResults: [Message] = [] @State private var isSearching = false @State private var searchPerformed = false + @State private var reportTarget: ReportTarget? = nil + @State private var blockedUserIds: Set = [] private var distinctTags: [String] { var seen = Set() @@ -64,7 +66,7 @@ struct FeedView: View { ContentUnavailableView.search(text: searchText) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - List(searchResults) { message in + List(searchResults.filter { !blockedUserIds.contains($0.userId ?? "") }) { message in MessageRow( message: message, currentUserId: authState.user?.id, @@ -75,7 +77,9 @@ struct FeedView: View { onEdit: { messageToEdit = message }, onDig: { Task { await toggleDig(for: message) } }, onRepost: { messageToRepost = message }, - onTapAuthor: { username in profileUsername = username } + onTapAuthor: { username in profileUsername = username }, + onReport: { reportTarget = .message(id: message.id) }, + onBlock: { Task { await blockUser(userId: message.userId ?? "", username: message.user?.username ?? "") } } ) } } @@ -111,7 +115,7 @@ struct FeedView: View { } } } - ForEach(messages) { message in + ForEach(messages.filter { !blockedUserIds.contains($0.userId ?? "") }) { message in MessageRow( message: message, currentUserId: authState.user?.id, @@ -128,7 +132,9 @@ struct FeedView: View { onEdit: { messageToEdit = message }, onDig: { Task { await toggleDig(for: message) } }, onRepost: { messageToRepost = message }, - onTapAuthor: { username in profileUsername = username } + onTapAuthor: { username in profileUsername = username }, + onReport: { reportTarget = .message(id: message.id) }, + onBlock: { Task { await blockUser(userId: message.userId ?? "", username: message.user?.username ?? "") } } ) } if let pagination = pagination, pagination.hasMore, !isLoading { @@ -189,6 +195,10 @@ struct FeedView: View { } message: { Text(deleteError ?? "This cannot be undone.") } + .sheet(item: $reportTarget) { target in + ReportSheet(target: target, onDismiss: { reportTarget = nil }) + .environmentObject(authState) + } } private var navigationContent: some View { @@ -322,6 +332,19 @@ struct FeedView: View { } } + private func blockUser(userId: String, username: String) async { + guard !userId.isEmpty else { return } + blockedUserIds.insert(userId) + do { + try await APIClient.shared.blockUser(id: userId) + } catch APIError.status(401) { + authState.handleUnauthorized() + blockedUserIds.remove(userId) + } catch { + blockedUserIds.remove(userId) + } + } + private func loadMessages() async { errorMessage = nil isLoading = true @@ -374,6 +397,8 @@ struct MessageRow: View { let onDig: () -> Void var onRepost: (() -> Void)? = nil var onTapAuthor: ((String) -> Void)? = nil + var onReport: (() -> Void)? = nil + var onBlock: (() -> Void)? = nil private var canDelete: Bool { guard let uid = currentUserId else { return false } @@ -440,50 +465,79 @@ struct MessageRow: View { if showPreviews && message.hasPreviews { previewSection } - HStack(spacing: 12) { + HStack(spacing: 16) { Button { onReply() } label: { - Label("Reply", systemImage: "arrowshape.turn.up.left") - .font(.ilMono()) - } - .buttonStyle(.borderless) - Button { - onDig() - } label: { - Label(effectiveDugByMe ? "Dug" : "Dig", systemImage: effectiveDugByMe ? "hand.thumbsup.fill" : "hand.thumbsup") + Image(systemName: "arrowshape.turn.up.left") .font(.ilMono()) } .buttonStyle(.borderless) - if effectiveDigCount > 0 { - Text("\(effectiveDigCount)") - .font(.ilMono()) - .foregroundStyle(.secondary) + .accessibilityLabel("Reply") + HStack(spacing: 4) { + Button { + onDig() + } label: { + Image(systemName: effectiveDugByMe ? "hand.thumbsup.fill" : "hand.thumbsup") + .font(.ilMono()) + } + .buttonStyle(.borderless) + .accessibilityLabel(effectiveDugByMe ? "Dug" : "Dig") + if effectiveDigCount > 0 { + Text("\(effectiveDigCount)") + .font(.ilMono()) + .foregroundStyle(.secondary) + } } if let onRepost { Button { onRepost() } label: { - Label("Repost", systemImage: "arrow.2.squarepath") + Image(systemName: "arrow.2.squarepath") .font(.ilMono()) } .buttonStyle(.borderless) + .accessibilityLabel("Repost") } if canDelete { Button { onEdit() } label: { - Label("Edit", systemImage: "pencil") + Image(systemName: "pencil") .font(.ilMono()) } .buttonStyle(.borderless) + .accessibilityLabel("Edit") Button(role: .destructive) { onDelete() } label: { - Label("Delete", systemImage: "trash") + Image(systemName: "trash") + .font(.ilMono()) + } + .buttonStyle(.borderless) + .accessibilityLabel("Delete") + } else if onReport != nil || onBlock != nil { + Menu { + if let onReport { + Button(role: .destructive) { + onReport() + } label: { + Label("Report…", systemImage: "flag") + } + } + if let onBlock { + Button(role: .destructive) { + onBlock() + } label: { + Label("Block user", systemImage: "person.slash") + } + } + } label: { + Image(systemName: "ellipsis") .font(.ilMono()) } .buttonStyle(.borderless) + .accessibilityLabel("More options") } } } diff --git a/InterlinedList/Views/MessageThreadView.swift b/InterlinedList/Views/MessageThreadView.swift index 18fb318..fa4d93f 100644 --- a/InterlinedList/Views/MessageThreadView.swift +++ b/InterlinedList/Views/MessageThreadView.swift @@ -13,6 +13,7 @@ struct MessageThreadView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var showReplyCompose = false + @State private var reportTarget: ReportTarget? = nil @EnvironmentObject var authState: AuthState var body: some View { @@ -72,6 +73,10 @@ struct MessageThreadView: View { ComposeView(replyTo: rootMessage) .environmentObject(authState) } + .sheet(item: $reportTarget) { target in + ReportSheet(target: target, onDismiss: { reportTarget = nil }) + .environmentObject(authState) + } } } @@ -121,6 +126,24 @@ struct MessageThreadView: View { if let tags = reply.tags, !tags.isEmpty { tagChips(tags) } + if reply.userId != currentUserId { + HStack { + Spacer() + Menu { + Button(role: .destructive) { + reportTarget = .message(id: reply.id) + } label: { + Label("Report…", systemImage: "flag") + } + } label: { + Image(systemName: "ellipsis") + .font(.ilMono()) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .accessibilityLabel("More options") + } + } } .padding(.vertical, 4) } diff --git a/InterlinedList/Views/RegisterView.swift b/InterlinedList/Views/RegisterView.swift index b077883..42bf992 100644 --- a/InterlinedList/Views/RegisterView.swift +++ b/InterlinedList/Views/RegisterView.swift @@ -4,6 +4,7 @@ // import SwiftUI +import SafariServices struct RegisterView: View { @EnvironmentObject var authState: AuthState @@ -19,6 +20,8 @@ struct RegisterView: View { @State private var oauthInFlight = false @State private var linkedinVisible = false @State private var twitterVisible = false + @State private var agreedToTerms = false + @State private var showTerms = false var body: some View { NavigationStack { @@ -49,6 +52,26 @@ struct RegisterView: View { } } + Section { + HStack(alignment: .top, spacing: 12) { + Toggle("", isOn: $agreedToTerms) + .labelsHidden() + .accessibilityLabel("Agree to Terms of Service") + VStack(alignment: .leading, spacing: 2) { + Text("I agree to the ") + .font(.ilBody()) + + Text("Terms of Service") + .font(.ilBody()) + .underline() + .foregroundStyle(ILColor.primary) + } + .onTapGesture { showTerms = true } + } + } footer: { + Text("You must agree to the Terms of Service to create an account.") + .font(.ilMono(10)) + } + Section { Button { Task { await register() } @@ -62,7 +85,7 @@ struct RegisterView: View { .frame(maxWidth: .infinity) } } - .disabled(isLoading || email.isEmpty || username.isEmpty || password.count < 8) + .disabled(isLoading || email.isEmpty || username.isEmpty || password.count < 8 || !agreedToTerms) } Section("Or sign up with") { @@ -98,6 +121,11 @@ struct RegisterView: View { } .task { await refreshOAuthVisibility() } .onAppear { errorMessage = nil } + .sheet(isPresented: $showTerms) { + if let url = URL(string: "https://interlinedlist.com/terms") { + SafariView(url: url).ignoresSafeArea() + } + } } private var visibleProviders: [OAuthProvider] { diff --git a/InterlinedList/Views/ReportSheet.swift b/InterlinedList/Views/ReportSheet.swift new file mode 100644 index 0000000..00f42ec --- /dev/null +++ b/InterlinedList/Views/ReportSheet.swift @@ -0,0 +1,124 @@ +// +// ReportSheet.swift +// InterlinedList +// + +import SwiftUI + +enum ReportTarget: Identifiable { + case message(id: String) + case user(id: String, username: String) + + var id: String { + switch self { + case .message(let id): return "message:\(id)" + case .user(let id, _): return "user:\(id)" + } + } + + var title: String { + switch self { + case .message: return "Report Post" + case .user(_, let username): return "Report @\(username)" + } + } +} + +struct ReportSheet: View { + let target: ReportTarget + let onDismiss: () -> Void + + @EnvironmentObject private var authState: AuthState + @State private var selectedReason: ReportReason = .spam + @State private var detail = "" + @State private var isSubmitting = false + @State private var submitted = false + @State private var error: String? + + var body: some View { + NavigationStack { + Form { + Section("Reason") { + Picker("Reason", selection: $selectedReason) { + ForEach(ReportReason.allCases) { reason in + Text(reason.displayName).tag(reason) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + + Section("Additional details (optional)") { + TextField("Describe the issue…", text: $detail, axis: .vertical) + .lineLimit(3...6) + .font(.ilBody()) + } + + if let error { + Section { + Text(error).foregroundStyle(.red).font(.ilMono()) + } + } + + if submitted { + Section { + Label("Report submitted. Thank you.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + } + .navigationTitle(target.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { onDismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if isSubmitting { + ProgressView() + } else if submitted { + Button("Done") { onDismiss() } + } else { + Button("Submit") { + Task { await submit() } + } + .accessibilityLabel("Submit report") + } + } + } + } + } + + private func submit() async { + error = nil + isSubmitting = true + defer { isSubmitting = false } + let detailText = detail.trimmingCharacters(in: .whitespacesAndNewlines) + do { + switch target { + case .message(let id): + try await APIClient.shared.reportMessage( + id: id, + reason: selectedReason, + detail: detailText.isEmpty ? nil : detailText + ) + case .user(let id, _): + try await APIClient.shared.reportUser( + id: id, + reason: selectedReason, + detail: detailText.isEmpty ? nil : detailText + ) + } + submitted = true + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + self.error = "Failed to submit report. Please try again." + } + } +} + +#Preview { + ReportSheet(target: .message(id: "abc"), onDismiss: {}) + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/SettingsView.swift b/InterlinedList/Views/SettingsView.swift index 60d42df..b3a7ef6 100644 --- a/InterlinedList/Views/SettingsView.swift +++ b/InterlinedList/Views/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { ("Blog", "/blog"), ("Pricing", "/pricing"), ("Terms of Service", "/terms"), + ("Community Guidelines", "/terms"), ("Privacy Policy", "/privacy"), ("Branding", "/help/branding"), ] @@ -34,6 +35,7 @@ struct SettingsView: View { postingSection accountsSection notificationsSection + moderationSection aboutSection if let settingsError { Section { Text(settingsError).font(.ilMono()).foregroundStyle(.red) } @@ -77,7 +79,12 @@ struct SettingsView: View { Text("Dark").tag("dark") } .onChange(of: theme) { _, newValue in - Task { await save(theme: newValue) } + // Guard against spurious saves when syncFromUser sets the initial value on appear. + let serverTheme = authState.user?.theme ?? "system" + guard newValue != serverTheme else { return } + // "system" means no explicit preference — send nil so the server clears it. + // Sending the string "system" is rejected or treated as default (light) by the server. + Task { await save(theme: newValue == "system" ? nil : newValue) } } } } @@ -86,10 +93,14 @@ struct SettingsView: View { Section("Posting") { Toggle("Default to public", isOn: $defaultPublic) .onChange(of: defaultPublic) { _, newValue in + let serverValue = authState.user?.defaultPubliclyVisible ?? true + guard newValue != serverValue else { return } Task { await save(defaultVisibility: newValue) } } Toggle("Show advanced post settings", isOn: $showAdvanced) .onChange(of: showAdvanced) { _, newValue in + let serverValue = authState.user?.showAdvancedPostSettings ?? false + guard newValue != serverValue else { return } Task { await save(showAdvancedPostSettings: newValue) } } if let maxLen = authState.user?.maxMessageLength { @@ -121,6 +132,16 @@ struct SettingsView: View { } } + private var moderationSection: some View { + Section("Safety") { + NavigationLink { + BlockedUsersView().environmentObject(authState) + } label: { + Label("Blocked users", systemImage: "person.slash") + } + } + } + private var aboutSection: some View { Section("About") { ForEach(aboutLinks, id: \.path) { link in @@ -154,6 +175,8 @@ struct SettingsView: View { authState.handleUnauthorized() } catch { settingsError = "Could not save settings." + // Revert local controls to match server state so they don't show a value that wasn't saved. + syncFromUser() } } } diff --git a/InterlinedList/Views/UserProfileView.swift b/InterlinedList/Views/UserProfileView.swift index d2738b2..685ea33 100644 --- a/InterlinedList/Views/UserProfileView.swift +++ b/InterlinedList/Views/UserProfileView.swift @@ -36,6 +36,9 @@ struct UserProfileView: View { @State private var isLoadingDocuments = false @State private var documentsError: String? @State private var documentsLoaded = false + @State private var showReportSheet = false + @State private var isBlocked = false + @State private var blockError: String? var body: some View { NavigationStack { @@ -69,6 +72,31 @@ struct UserProfileView: View { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } + if authState.user?.username != username { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(role: .destructive) { + showReportSheet = true + } label: { + Label("Report @\(username)…", systemImage: "flag") + } + Button(role: .destructive) { + Task { await toggleBlock() } + } label: { + Label(isBlocked ? "Unblock @\(username)" : "Block @\(username)", systemImage: "person.slash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("More options") + } + } + } + .sheet(isPresented: $showReportSheet) { + if let uid = targetUserId { + ReportSheet(target: .user(id: uid, username: username), onDismiss: { showReportSheet = false }) + .environmentObject(authState) + } } .task { await loadMessages() @@ -501,6 +529,24 @@ struct UserProfileView: View { followError = "Action failed. Please try again." } } + + private func toggleBlock() async { + guard let uid = targetUserId else { return } + blockError = nil + do { + if isBlocked { + try await APIClient.shared.unblockUser(id: uid) + isBlocked = false + } else { + try await APIClient.shared.blockUser(id: uid) + isBlocked = true + } + } catch APIError.status(401) { + authState.handleUnauthorized() + } catch { + blockError = isBlocked ? "Could not unblock user." : "Could not block user." + } + } } private struct ShareSheet: UIViewControllerRepresentable { diff --git a/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift b/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift index ba1dfb9..ef615c8 100644 --- a/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientImageUploadTests.swift @@ -91,4 +91,95 @@ final class APIClientImageUploadTests: XCTestCase { XCTAssertEqual(code, 403) } } + + // MARK: uploadDocumentImage() + + func test_uploadDocumentImage_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/documents/abc123/images/upload") + } + + func test_uploadDocumentImage_encodesDocumentIdInPath() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) + _ = try await sut.uploadDocumentImage(documentId: "doc with spaces", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + // url?.path is always percent-decoded by Foundation; check the raw absoluteString instead. + let raw = session.lastRequest?.url?.absoluteString ?? "" + XCTAssertFalse(raw.contains(" "), "URL string must not contain raw spaces") + XCTAssertTrue(raw.hasSuffix("/images/upload")) + } + + func test_uploadDocumentImage_contentTypeIsMultipart() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + let ct = session.lastRequest?.value(forHTTPHeaderField: "Content-Type") ?? "" + XCTAssertTrue(ct.hasPrefix("multipart/form-data")) + } + + func test_uploadDocumentImage_sendsBearerToken() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_uploadDocumentImage_returnsURL() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) + let url = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTAssertEqual(url, "https://cdn.example.com/doc-img.jpg") + } + + func test_uploadDocumentImage_jpegUsesJpgExtension() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + let body = session.lastRequest?.httpBody ?? Data() + XCTAssertNotNil(body.range(of: Data(#"filename="upload.jpg""#.utf8)), + "Multipart body should declare a .jpg filename for JPEG") + } + + func test_uploadDocumentImage_pngUsesPngExtension() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.png"}"#) + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0x89, 0x50]), mimeType: "image/png") + let body = session.lastRequest?.httpBody ?? Data() + XCTAssertNotNil(body.range(of: Data(#"filename="upload.png""#.utf8)), + "Multipart body should declare a .png filename for PNG") + } + + func test_uploadDocumentImage_multipartFieldNameIsImage() async throws { + session.stub(json: #"{"url":"https://cdn.example.com/doc-img.jpg"}"#) + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + let body = session.lastRequest?.httpBody ?? Data() + XCTAssertNotNil(body.range(of: Data(#"name="image""#.utf8)), + "Multipart form field must be named 'image'") + } + + func test_uploadDocumentImage_401_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 401) + do { + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTFail("Expected APIError.status(401)") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + func test_uploadDocumentImage_403_throwsStatusError() async throws { + session.stub(data: Data(), statusCode: 403) + do { + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTFail("Expected APIError.status(403)") + } catch APIError.status(let code) { + XCTAssertEqual(code, 403) + } + } + + func test_uploadDocumentImage_serverError_throwsServerError() async throws { + session.stub(json: #"{"error":"unsupported format"}"#, statusCode: 422) + do { + _ = try await sut.uploadDocumentImage(documentId: "abc123", data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTFail("Expected APIError.server") + } catch APIError.server(let msg) { + XCTAssertEqual(msg, "unsupported format") + } + } } diff --git a/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift b/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift index 8fd7d4d..fb8a5d9 100644 --- a/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientMessagesTests.swift @@ -152,6 +152,27 @@ final class APIClientMessagesTests: XCTestCase { } } + func test_postMessage_withOrganizationId_sendsCamelCaseKeyInBody() async throws { + let wrapped = #"{"data":\#(messageJSON)}"# + session.stub(json: wrapped) + _ = try await sut.postMessage(content: "Org post", organizationId: "org-42") + let body = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["organizationId"] as? String, "org-42", + "organizationId must appear as camelCase in the request body") + XCTAssertNil(json["organization_id"], "snake_case key must not be sent") + } + + func test_postMessage_withoutOrganizationId_omitsFieldFromBody() async throws { + let wrapped = #"{"data":\#(messageJSON)}"# + session.stub(json: wrapped) + _ = try await sut.postMessage(content: "Plain post") + let body = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertNil(json["organizationId"], "organizationId must be absent when not provided") + XCTAssertNil(json["organization_id"]) + } + // MARK: editMessage() func test_editMessage_sendsPutToCorrectPath() async throws { diff --git a/InterlinedListTests/APIClientTests/APIClientModerationTests.swift b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift new file mode 100644 index 0000000..9b00ccb --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientModerationTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import InterlinedList + +final class APIClientModerationTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: reportMessage() + + func test_reportMessage_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportMessage(id: "msg-id", reason: .spam, detail: nil) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/messages/msg-id/report") + } + + func test_reportMessage_sendsBearerToken() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportMessage(id: "msg-id", reason: .spam, detail: nil) + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_reportMessage_bodyContainsReason() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportMessage(id: "msg-id", reason: .spam, detail: nil) + let bodyData = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + XCTAssertEqual(json?["reason"] as? String, "spam") + } + + func test_reportMessage_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.reportMessage(id: "msg-id", reason: .spam, detail: nil) + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: reportUser() + + func test_reportUser_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"reported":true}"#) + try await sut.reportUser(id: "user-id", reason: .harassment, detail: nil) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/report") + } + + // MARK: blockUser() + + func test_blockUser_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"blocked":true}"#) + try await sut.blockUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/block") + } + + func test_blockUser_httpMethodIsPost() async throws { + session.stub(json: #"{"blocked":true}"#) + try await sut.blockUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + } + + // MARK: unblockUser() + + func test_unblockUser_sendsDeleteToCorrectPath() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unblockUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/block") + } + + // MARK: blockedUsers() + + func test_blockedUsers_sendsGetRequest() async throws { + session.stub(json: #"{"blockedUsers":[],"pagination":null}"#) + _ = try await sut.blockedUsers() + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertTrue(session.lastRequest?.url?.path.hasPrefix("/api/user/blocks") == true) + } + + func test_blockedUsers_decodesResponse() async throws { + session.stub(json: #"{"blockedUsers":[{"id":"u1","username":"alice","displayName":null,"avatar":null}],"pagination":null}"#) + let response = try await sut.blockedUsers() + XCTAssertEqual(response.blockedUsers.count, 1) + XCTAssertEqual(response.blockedUsers.first?.id, "u1") + XCTAssertEqual(response.blockedUsers.first?.username, "alice") + } + + // MARK: muteUser() + + func test_muteUser_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"muted":true}"#) + try await sut.muteUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/mute") + } + + // MARK: unmuteUser() + + func test_unmuteUser_sendsDeleteRequest() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unmuteUser(id: "user-id") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/users/user-id/mute") + } + + // MARK: mutedUsers() + + func test_mutedUsers_decodesResponse() async throws { + session.stub(json: #"{"mutedUsers":[{"id":"u2","username":"bob","displayName":null,"avatar":null}],"pagination":null}"#) + let response = try await sut.mutedUsers() + XCTAssertEqual(response.mutedUsers.count, 1) + XCTAssertEqual(response.mutedUsers.first?.id, "u2") + XCTAssertEqual(response.mutedUsers.first?.username, "bob") + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientPushTests.swift b/InterlinedListTests/APIClientTests/APIClientPushTests.swift new file mode 100644 index 0000000..75829f5 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientPushTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import InterlinedList + +final class APIClientPushTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: registerPushDevice() + + func test_registerPushDevice_sendsPostToCorrectPath() async throws { + session.stub(json: #"{"registered":true}"#) + try await sut.registerPushDevice(token: "abc") + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/push/register") + } + + func test_registerPushDevice_sendsBearerToken() async throws { + session.stub(json: #"{"registered":true}"#) + try await sut.registerPushDevice(token: "abc") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + func test_registerPushDevice_bodyContainsTokenAndPlatform() async throws { + session.stub(json: #"{"registered":true}"#) + try await sut.registerPushDevice(token: "abc") + let bodyData = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + XCTAssertEqual(json?["token"] as? String, "abc") + XCTAssertEqual(json?["platform"] as? String, "ios") + } + + func test_registerPushDevice_401_throws() async throws { + session.stub(data: Data(), statusCode: 401) + do { + try await sut.registerPushDevice(token: "abc") + XCTFail("Expected throw") + } catch APIError.status(let code) { + XCTAssertEqual(code, 401) + } + } + + // MARK: unregisterPushDevice() + + func test_unregisterPushDevice_sendsDeleteToCorrectPath() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unregisterPushDevice(token: "abc") + XCTAssertEqual(session.lastRequest?.httpMethod, "DELETE") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/push/unregister") + } + + func test_unregisterPushDevice_bodyContainsToken() async throws { + session.stub(data: Data(), statusCode: 200) + try await sut.unregisterPushDevice(token: "abc") + let bodyData = try XCTUnwrap(session.lastRequest?.httpBody) + let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + XCTAssertEqual(json?["token"] as? String, "abc") + } +} diff --git a/InterlinedListTests/ModelTests/MessageModelTests.swift b/InterlinedListTests/ModelTests/MessageModelTests.swift index 117aced..664ca89 100644 --- a/InterlinedListTests/ModelTests/MessageModelTests.swift +++ b/InterlinedListTests/ModelTests/MessageModelTests.swift @@ -151,6 +151,72 @@ final class MessageCrossPostUrlTests: XCTestCase { } } +final class CreateMessageBodyEncodingTests: XCTestCase { + // camelCaseEncoder is the plain JSONEncoder (no key strategy); keys are + // already camelCase in the struct, so they survive encoding unchanged. + private let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = .sortedKeys + return e + }() + + private func encodedJSON(_ body: CreateMessageBody) throws -> [String: Any] { + let data = try encoder.encode(body) + return try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func makeBody( + content: String = "Test", + publiclyVisible: Bool? = nil, + parentId: String? = nil, + tags: [String]? = nil, + scheduledAt: String? = nil, + imageUrls: [String]? = nil, + videoUrls: [String]? = nil, + pushedMessageId: String? = nil, + mastodonProviderIds: [String]? = nil, + crossPostToBluesky: Bool? = nil, + crossPostToLinkedIn: Bool? = nil, + linkedInTargets: [LinkedInTarget]? = nil, + linkedInLinkAsFirstComment: Bool? = nil, + crossPostToTwitter: Bool? = nil, + scheduledCrossPostConfig: ScheduledCrossPostConfig? = nil, + organizationId: String? = nil + ) -> CreateMessageBody { + CreateMessageBody( + content: content, publiclyVisible: publiclyVisible, parentId: parentId, + tags: tags, scheduledAt: scheduledAt, imageUrls: imageUrls, videoUrls: videoUrls, + pushedMessageId: pushedMessageId, mastodonProviderIds: mastodonProviderIds, + crossPostToBluesky: crossPostToBluesky, crossPostToLinkedIn: crossPostToLinkedIn, + linkedInTargets: linkedInTargets, linkedInLinkAsFirstComment: linkedInLinkAsFirstComment, + crossPostToTwitter: crossPostToTwitter, scheduledCrossPostConfig: scheduledCrossPostConfig, + organizationId: organizationId + ) + } + + func test_encode_organizationId_presentWhenSet() throws { + let body = makeBody(content: "Org post", publiclyVisible: true, organizationId: "org-42") + let json = try encodedJSON(body) + XCTAssertEqual(json["organizationId"] as? String, "org-42") + XCTAssertNil(json["organization_id"]) + } + + func test_encode_organizationId_absentWhenNil() throws { + let body = makeBody(content: "Personal post") + let json = try encodedJSON(body) + XCTAssertNil(json["organizationId"]) + } + + func test_encode_organizationId_doesNotAffectOtherFields() throws { + let body = makeBody(content: "Hello", publiclyVisible: true, tags: ["swift"], organizationId: "org-99") + let json = try encodedJSON(body) + XCTAssertEqual(json["content"] as? String, "Hello") + XCTAssertEqual(json["publiclyVisible"] as? Bool, true) + XCTAssertEqual(json["tags"] as? [String], ["swift"]) + XCTAssertEqual(json["organizationId"] as? String, "org-99") + } +} + final class PaginationCodableTests: XCTestCase { private let decoder: JSONDecoder = { let d = JSONDecoder()