diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1513df5..1da6aa5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -98,7 +98,13 @@ "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')", "Bash(brew doctor *)", - "Bash(brew upgrade *)" + "Bash(brew upgrade *)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers', {}\\), indent=2\\)\\)\")", + "Read(//Users/adron/.claude/**)", + "Bash(ruby *)", + "mcp__xcodebuildmcp__session_show_defaults", + "mcp__xcodebuildmcp__list_sims", + "mcp__xcodebuildmcp__build_sim" ], "additionalDirectories": [ "/Users/adron/Codez/interlinedlist-ios/.claude" diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index f318815..1e43b10 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -7,12 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */; }; 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 */; }; 2101251E75F418E9EB28DA8F /* ImageUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */; }; 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */; }; + 388E7B579881268E86808945 /* ReorderSpikeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104AAB22C8CEDBE47729581D /* ReorderSpikeView.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 */; }; @@ -20,6 +22,7 @@ 6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; }; 7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; }; 8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; }; + 99E688DB379BD4A824487604 /* ComposeImageUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */; }; 9CA0B53AE87AA79D8BAC81FF /* FollowListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF29F5DC946D80A63000209 /* FollowListView.swift */; }; A1B1C1D1E1F10001 /* InterlinedListApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10002 /* InterlinedListApp.swift */; }; A1B1C1D1E1F10003 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10004 /* RootView.swift */; }; @@ -76,6 +79,7 @@ D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; }; E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */; }; F93C3A953A0B809D718F9DB5 /* PublicDocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */; }; + FCA5249B389CBC18040D6011 /* ComposeImageStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */; }; T1E5T1E5T1E50001 /* InterlinedListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50002 /* InterlinedListTests.swift */; }; T1E5T1E5T1E50003 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50004 /* MockURLSession.swift */; }; T1E5T1E5T1E50005 /* APIClientAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = T1E5T1E5T1E50006 /* APIClientAuthTests.swift */; }; @@ -130,8 +134,10 @@ /* 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 = ""; }; + 104AAB22C8CEDBE47729581D /* ReorderSpikeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReorderSpikeView.swift; sourceTree = ""; }; 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; }; + 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageStrip.swift; sourceTree = ""; }; 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; path = AppDataStoreTests.swift; sourceTree = ""; }; @@ -141,6 +147,7 @@ 7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = ""; }; 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessorTests.swift; sourceTree = ""; }; 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/SpaceGrotesk.ttf; sourceTree = SOURCE_ROOT; }; + 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploader.swift; sourceTree = ""; }; 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 = ""; }; @@ -194,6 +201,7 @@ 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 = ""; }; + D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.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 = ""; }; @@ -337,6 +345,7 @@ FC5914D730B4355F02E89D81 /* ILColor.swift */, 8B79D26B822DCA1188DF37E9 /* PushService.swift */, 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */, + 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */, ); path = Services; sourceTree = ""; @@ -380,6 +389,8 @@ 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */, 7800699C927CE3E133944E62 /* ReportSheet.swift */, E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */, + 104AAB22C8CEDBE47729581D /* ReorderSpikeView.swift */, + 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */, ); path = Views; sourceTree = ""; @@ -455,6 +466,7 @@ T1E5T1E5T1E5P016 /* KeychainServiceTests.swift */, 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */, 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */, + D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */, ); path = ServiceTests; sourceTree = ""; @@ -618,6 +630,9 @@ 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */, 17CD6CF35900DC95AA284E3C /* PushService.swift in Sources */, C18C73C8A93572993F29D559 /* ImageUploadProcessor.swift in Sources */, + 388E7B579881268E86808945 /* ReorderSpikeView.swift in Sources */, + 99E688DB379BD4A824487604 /* ComposeImageUploader.swift in Sources */, + FCA5249B389CBC18040D6011 /* ComposeImageStrip.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -670,6 +685,7 @@ 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */, CB45081F767583424D54523C /* APIClientPushTests.swift in Sources */, 2101251E75F418E9EB28DA8F /* ImageUploadProcessorTests.swift in Sources */, + 09037C64E9D988371F29AD75 /* ComposeImageUploaderTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Services/ComposeImageUploader.swift b/InterlinedList/Services/ComposeImageUploader.swift new file mode 100644 index 0000000..63afec2 --- /dev/null +++ b/InterlinedList/Services/ComposeImageUploader.swift @@ -0,0 +1,165 @@ +// +// ComposeImageUploader.swift +// InterlinedList +// + +import Foundation +import PhotosUI +import SwiftUI +import UIKit + +/// Coordinates attaching up to `maxImages` images to a compose draft: reserves +/// ordered slots (capped), normalizes + uploads each picked image, and tracks +/// per-image status so the UI can show progress, retry failures, and post the +/// images that succeeded. HTTP stays in `APIClient`; this owns only compose state. +@MainActor +final class ComposeImageUploader: ObservableObject { + nonisolated static let maxImages = 8 + + struct Attachment: Identifiable { + let id: UUID + var preview: UIImage? + var status: Status + + enum Status: Equatable { + case uploading + case uploaded(String) + case failed + } + } + + @Published private(set) var attachments: [Attachment] = [] + + /// Retained per attachment so a failed upload can be retried from its source. + private var sources: [UUID: PhotosPickerItem] = [:] + + /// Injected so tests exercise the state machine without a live network. + private let uploadBytes: (Data, String) async throws -> String + + init(uploadBytes: @escaping (Data, String) async throws -> String = { data, mime in + try await APIClient.shared.uploadImage(data: data, mimeType: mime) + }) { + self.uploadBytes = uploadBytes + } + + // MARK: - Derived state + + var count: Int { attachments.count } + var isEmpty: Bool { attachments.isEmpty } + var remainingSlots: Int { max(0, Self.maxImages - attachments.count) } + var isUploading: Bool { attachments.contains { $0.status == .uploading } } + var hasFailures: Bool { attachments.contains { $0.status == .failed } } + + /// Successfully-uploaded URLs, in attachment (display) order. + var uploadedURLs: [String] { + attachments.compactMap { + if case .uploaded(let url) = $0.status { return url } + return nil + } + } + + // MARK: - State machine (unit-tested) + + /// Reserve an ordered slot if under the cap. Returns its id, or nil when full. + @discardableResult + func reserve(preview: UIImage? = nil) -> UUID? { + guard attachments.count < Self.maxImages else { return nil } + let id = UUID() + attachments.append(Attachment(id: id, preview: preview, status: .uploading)) + return id + } + + /// Upload already-loaded bytes and record the outcome for `id`. + func performUpload(id: UUID, data: Data, mimeType: String) async { + setStatus(.uploading, for: id) + do { + let url = try await uploadBytes(data, mimeType) + setStatus(.uploaded(url), for: id) + } catch { + setStatus(.failed, for: id) + } + } + + func remove(_ id: UUID) { + attachments.removeAll { $0.id == id } + sources[id] = nil + } + + func reset() { + attachments.removeAll() + sources.removeAll() + } + + // MARK: - PhotosPicker integration + + /// Ingest newly-picked items: reserve slots (respecting the cap), then load, + /// normalize, and upload each with bounded concurrency. + func add(_ items: [PhotosPickerItem]) async { + var reserved: [(id: UUID, item: PhotosPickerItem)] = [] + for item in items { + guard let id = reserve() else { break } // cap reached + sources[id] = item + reserved.append((id, item)) + } + await withTaskGroup(of: Void.self) { group in + let maxConcurrent = 3 + var running = 0 + for entry in reserved { + if running >= maxConcurrent { + await group.next() + running -= 1 + } + group.addTask { await self.loadNormalizeUpload(id: entry.id, item: entry.item) } + running += 1 + } + while await group.next() != nil {} + } + } + + /// Retry a failed attachment from its original picked source. + func retry(_ id: UUID) async { + guard let item = sources[id] else { + setStatus(.failed, for: id) + return + } + await loadNormalizeUpload(id: id, item: item) + } + + private func loadNormalizeUpload(id: UUID, item: PhotosPickerItem) async { + setStatus(.uploading, for: id) + guard let raw = try? await item.loadTransferable(type: Data.self) else { + setStatus(.failed, for: id) + return + } + let processed = await Task.detached(priority: .userInitiated) { + ImageUploadProcessor.process(raw) + }.value + let data = processed?.data ?? raw + let mimeType = processed?.mimeType ?? (item.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg") + setPreview(UIImage(data: data), for: id) + await performUpload(id: id, data: data, mimeType: mimeType) + } + + // MARK: - Private helpers + + private func index(of id: UUID) -> Int? { attachments.firstIndex { $0.id == id } } + + private func setStatus(_ status: Attachment.Status, for id: UUID) { + guard let i = index(of: id) else { return } + attachments[i].status = status + } + + private func setPreview(_ image: UIImage?, for id: UUID) { + guard let i = index(of: id) else { return } + attachments[i].preview = image + } + + #if DEBUG + /// Seed an uploader with fixed statuses for SwiftUI previews. + static func previewSeed(_ statuses: [Attachment.Status]) -> ComposeImageUploader { + let uploader = ComposeImageUploader(uploadBytes: { _, _ in "" }) + uploader.attachments = statuses.map { Attachment(id: UUID(), preview: nil, status: $0) } + return uploader + } + #endif +} diff --git a/InterlinedList/Views/ComposeImageStrip.swift b/InterlinedList/Views/ComposeImageStrip.swift new file mode 100644 index 0000000..612271e --- /dev/null +++ b/InterlinedList/Views/ComposeImageStrip.swift @@ -0,0 +1,106 @@ +// +// ComposeImageStrip.swift +// InterlinedList +// + +import SwiftUI + +/// Horizontal strip of the images attached to a compose draft. Shows each +/// image's upload progress, a retry affordance on failure, and a remove button. +struct ComposeImageStrip: View { + @ObservedObject var uploader: ComposeImageUploader + + private let side: CGFloat = 76 + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(uploader.attachments) { attachment in + thumbnail(attachment) + } + } + .padding(.vertical, 2) + } + .frame(height: side + 12) + } + + @ViewBuilder + private func thumbnail(_ attachment: ComposeImageUploader.Attachment) -> some View { + ZStack(alignment: .topTrailing) { + image(attachment) + .frame(width: side, height: side) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { statusOverlay(attachment) } + removeButton(attachment.id) + .padding(3) + } + } + + @ViewBuilder + private func image(_ attachment: ComposeImageUploader.Attachment) -> some View { + if let preview = attachment.preview { + Image(uiImage: preview) + .resizable() + .scaledToFill() + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Color(.secondarySystemBackground)) + } + } + + @ViewBuilder + private func statusOverlay(_ attachment: ComposeImageUploader.Attachment) -> some View { + switch attachment.status { + case .uploading: + ZStack { + Color.black.opacity(0.25) + ProgressView() + .tint(.white) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + case .failed: + Button { + Task { await uploader.retry(attachment.id) } + } label: { + ZStack { + Color.black.opacity(0.5) + VStack(spacing: 2) { + Image(systemName: "arrow.clockwise") + Text("Retry").font(.ilMono(11)) + } + .foregroundStyle(.white) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .accessibilityLabel("Retry failed image upload") + case .uploaded: + EmptyView() + } + } + + private func removeButton(_ id: UUID) -> some View { + Button { + uploader.remove(id) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.ilBody(16)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.55)) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove image") + } +} + +#Preview { + ComposeImageStrip( + uploader: .previewSeed([ + .uploaded("a"), + .uploading, + .failed, + .uploaded("b") + ]) + ) + .padding() +} diff --git a/InterlinedList/Views/ComposeView.swift b/InterlinedList/Views/ComposeView.swift index 7e0a3d4..5c60932 100644 --- a/InterlinedList/Views/ComposeView.swift +++ b/InterlinedList/Views/ComposeView.swift @@ -27,9 +27,8 @@ struct ComposeView: View { @State private var errorMessage: String? @State private var showSuccess = false @State private var showAdvancedBar = false - @State private var selectedPhoto: PhotosPickerItem? - @State private var uploadedImageURL: String? - @State private var isUploadingImage = false + @StateObject private var imageUploader = ComposeImageUploader() + @State private var photoSelection: [PhotosPickerItem] = [] @State private var selectedVideo: PhotosPickerItem? @State private var uploadedVideoURL: String? @State private var isUploadingVideo = false @@ -100,8 +99,8 @@ struct ComposeView: View { if showSchedulePicker && !isReply && canUseSubscriberFeatures { schedulePicker } - if let url = uploadedImageURL { - uploadedImagePreview(url: url) + if !imageUploader.attachments.isEmpty { + ComposeImageStrip(uploader: imageUploader) } if let url = uploadedVideoURL { uploadedVideoPreview(url: url) @@ -167,8 +166,8 @@ struct ComposeView: View { .alert(successTitle, isPresented: $showSuccess) { Button("OK") { content = "" - uploadedImageURL = nil - selectedPhoto = nil + imageUploader.reset() + photoSelection = [] uploadedVideoURL = nil selectedVideo = nil scheduledDate = nil @@ -185,9 +184,11 @@ struct ComposeView: View { } message: { Text(successMessage) } - .onChange(of: selectedPhoto) { _, newItem in - guard let newItem else { return } - Task { await uploadPhoto(newItem) } + .onChange(of: photoSelection) { _, items in + guard !items.isEmpty else { return } + let picked = items + photoSelection = [] + Task { await imageUploader.add(picked) } } } } @@ -214,7 +215,10 @@ struct ComposeView: View { /// Reposts may have empty commentary; everything else requires content. private var canSubmit: Bool { - isRepost || !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard !imageUploader.isUploading else { return false } + if isRepost { return true } + if !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } + return !imageUploader.uploadedURLs.isEmpty } private var successTitle: String { @@ -254,6 +258,31 @@ struct ComposeView: View { return base } + @ViewBuilder + private var attachPhotosButton: some View { + let attached = imageUploader.count + PhotosPicker( + selection: $photoSelection, + maxSelectionCount: max(1, imageUploader.remainingSlots), + selectionBehavior: .ordered, + matching: .images + ) { + HStack(spacing: 4) { + Image(systemName: attached > 0 ? "photo.fill.on.rectangle.fill" : "photo") + .font(.ilBody()) + .foregroundStyle(attached > 0 ? ILColor.primary : Color.secondary) + if attached > 0 { + Text("\(attached)/\(ComposeImageUploader.maxImages)") + .font(.ilMono()) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.borderless) + .disabled(imageUploader.remainingSlots == 0) + .accessibilityLabel("Attach photos") + } + @ViewBuilder private var advancedToolbar: some View { HStack(alignment: .center, spacing: 8) { @@ -275,19 +304,7 @@ struct ComposeView: View { } if showAdvancedBar && canUseSubscriberFeatures { HStack(spacing: 12) { - PhotosPicker(selection: $selectedPhoto, matching: .images) { - if isUploadingImage { - ProgressView() - .frame(width: 20, height: 20) - } else { - Image(systemName: uploadedImageURL != nil ? "photo.fill.on.rectangle.fill" : "photo") - .font(.ilBody()) - .foregroundStyle(uploadedImageURL != nil ? ILColor.primary : Color.secondary) - } - } - .buttonStyle(.borderless) - .disabled(isUploadingImage) - .accessibilityLabel("Attach photo") + attachPhotosButton PhotosPicker(selection: $selectedVideo, matching: .videos) { if isUploadingVideo { ProgressView() @@ -349,36 +366,6 @@ struct ComposeView: View { .buttonStyle(.borderless) } - @ViewBuilder - private func uploadedImagePreview(url: String) -> some View { - HStack { - if let imageURL = URL(string: url) { - AsyncImage(url: imageURL) { phase in - if let image = phase.image { - image.resizable().scaledToFill() - } else { - Image(systemName: "photo").foregroundStyle(.secondary) - } - } - .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - Text("Image attached") - .font(.ilMono()) - .foregroundStyle(.secondary) - Spacer() - Button { - uploadedImageURL = nil - selectedPhoto = nil - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - .accessibilityLabel("Remove attached image") - } - } - @ViewBuilder private func uploadedVideoPreview(url: String) -> some View { HStack { @@ -549,31 +536,6 @@ struct ComposeView: View { } } - private func uploadPhoto(_ item: PhotosPickerItem) async { - isUploadingImage = true - errorMessage = nil - defer { isUploadingImage = false } - do { - guard let rawData = try await item.loadTransferable(type: Data.self) else { return } - let (uploadData, mimeType): (Data, String) - if let processed = await Task.detached(priority: .userInitiated, operation: { - ImageUploadProcessor.process(rawData) - }).value { - uploadData = processed.data - mimeType = processed.mimeType - } else { - uploadData = rawData - mimeType = item.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg" - } - uploadedImageURL = try await APIClient.shared.uploadImage(data: uploadData, mimeType: mimeType) - } catch { - // Picker is hidden for non-subscribers; 403 falls through here. - composeLog.error("uploadPhoto failed: \(error)") - errorMessage = "Failed to upload image: \(error.localizedDescription)" - selectedPhoto = nil - } - } - private func postMessage() async { errorMessage = nil isLoading = true @@ -584,7 +546,8 @@ struct ComposeView: View { let isoScheduled = scheduledDate.map { ISO8601DateFormatter().string(from: $0) } - let urls = uploadedImageURL.map { [$0] } + let uploadedImages = imageUploader.uploadedURLs + let urls = uploadedImages.isEmpty ? nil : uploadedImages let videoUrls = uploadedVideoURL.map { [$0] } // Cross-post params only when the user is a subscriber and not replying. let crossPostEnabled = canUseSubscriberFeatures && !isReply diff --git a/InterlinedList/Views/ReorderSpikeView.swift b/InterlinedList/Views/ReorderSpikeView.swift new file mode 100644 index 0000000..2f8ecf5 --- /dev/null +++ b/InterlinedList/Views/ReorderSpikeView.swift @@ -0,0 +1,82 @@ +// +// ReorderSpikeView.swift +// InterlinedList +// +// SPIKE — throwaway. Proves horizontal drag-to-reorder of a thumbnail strip is +// feasible on iOS 17 before committing it to a follow-up PR. Remove before merge. +// + +import SwiftUI +import UniformTypeIdentifiers + +private struct SpikeTile: Identifiable, Equatable { + let id: Int + let color: Color +} + +struct ReorderSpikeView: View { + @State private var tiles: [SpikeTile] = [ + .init(id: 0, color: .red), .init(id: 1, color: .orange), .init(id: 2, color: .yellow), + .init(id: 3, color: .green), .init(id: 4, color: .blue), .init(id: 5, color: .purple) + ] + @State private var dragging: SpikeTile? + + var body: some View { + VStack(alignment: .leading) { + Text("order: " + tiles.map { String($0.id) }.joined(separator: ",")) + .font(.footnote.monospaced()) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(tiles) { tile in + RoundedRectangle(cornerRadius: 8) + .fill(tile.color) + .frame(width: 72, height: 72) + .overlay(Text("\(tile.id)").foregroundStyle(.white).bold()) + .opacity(dragging == tile ? 0.35 : 1) + .onDrag { + dragging = tile + return NSItemProvider(object: String(tile.id) as NSString) + } preview: { + RoundedRectangle(cornerRadius: 8).fill(tile.color) + .frame(width: 72, height: 72) + } + .onDrop( + of: [UTType.text], + delegate: ReorderDropDelegate(item: tile, tiles: $tiles, dragging: $dragging) + ) + } + } + .padding() + } + } + } +} + +private struct ReorderDropDelegate: DropDelegate { + let item: SpikeTile + @Binding var tiles: [SpikeTile] + @Binding var dragging: SpikeTile? + + func dropEntered(info: DropInfo) { + guard let dragging, dragging != item, + let from = tiles.firstIndex(of: dragging), + let to = tiles.firstIndex(of: item) else { return } + if tiles[to] != dragging { + withAnimation { + tiles.move(fromOffsets: IndexSet(integer: from), + toOffset: to > from ? to + 1 : to) + } + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } + + func performDrop(info: DropInfo) -> Bool { + dragging = nil + return true + } +} + +#Preview { + ReorderSpikeView() +} diff --git a/InterlinedListTests/ServiceTests/ComposeImageUploaderTests.swift b/InterlinedListTests/ServiceTests/ComposeImageUploaderTests.swift new file mode 100644 index 0000000..c3167c6 --- /dev/null +++ b/InterlinedListTests/ServiceTests/ComposeImageUploaderTests.swift @@ -0,0 +1,110 @@ +// +// ComposeImageUploaderTests.swift +// InterlinedListTests +// + +import XCTest +@testable import InterlinedList + +private struct StubUploadError: Error {} + +@MainActor +final class ComposeImageUploaderTests: XCTestCase { + + /// Echoes the uploaded bytes back as the URL, so ordering is easy to assert. + private func echoUploader() -> ComposeImageUploader { + ComposeImageUploader(uploadBytes: { data, _ in String(decoding: data, as: UTF8.self) }) + } + + private func upload(_ uploader: ComposeImageUploader, _ id: UUID, _ text: String) async { + await uploader.performUpload(id: id, data: Data(text.utf8), mimeType: "image/jpeg") + } + + func testReserveCapsAtEight() { + let uploader = echoUploader() + var reserved = 0 + for _ in 0..<12 where uploader.reserve() != nil { reserved += 1 } + XCTAssertEqual(reserved, ComposeImageUploader.maxImages) + XCTAssertEqual(uploader.count, 8) + XCTAssertEqual(uploader.remainingSlots, 0) + } + + func testUploadedURLsPreserveAttachmentOrder() async { + let uploader = echoUploader() + guard let a = uploader.reserve(), let b = uploader.reserve(), let c = uploader.reserve() else { + return XCTFail("reserve failed") + } + // Complete out of order; result order should still follow attachment order. + await upload(uploader, c, "C") + await upload(uploader, a, "A") + await upload(uploader, b, "B") + XCTAssertEqual(uploader.uploadedURLs, ["A", "B", "C"]) + XCTAssertFalse(uploader.isUploading) + XCTAssertFalse(uploader.hasFailures) + } + + func testPartialFailureExcludesOnlyTheFailedImage() async { + let uploader = ComposeImageUploader(uploadBytes: { data, _ in + let text = String(decoding: data, as: UTF8.self) + if text == "BAD" { throw StubUploadError() } + return text + }) + guard let a = uploader.reserve(), let bad = uploader.reserve(), let c = uploader.reserve() else { + return XCTFail("reserve failed") + } + await upload(uploader, a, "A") + await upload(uploader, bad, "BAD") + await upload(uploader, c, "C") + XCTAssertEqual(uploader.uploadedURLs, ["A", "C"]) + XCTAssertTrue(uploader.hasFailures) + } + + func testRemoveDropsAttachmentAndFreesSlot() async { + let uploader = echoUploader() + guard let a = uploader.reserve(), let b = uploader.reserve() else { + return XCTFail("reserve failed") + } + await upload(uploader, a, "A") + await upload(uploader, b, "B") + uploader.remove(a) + XCTAssertEqual(uploader.uploadedURLs, ["B"]) + XCTAssertEqual(uploader.count, 1) + XCTAssertEqual(uploader.remainingSlots, 7) + } + + func testRetryOfFailedUploadSucceeds() async { + actor Attempts { var n = 0; func next() -> Int { n += 1; return n } } + let attempts = Attempts() + let uploader = ComposeImageUploader(uploadBytes: { _, _ in + let n = await attempts.next() + if n == 1 { throw StubUploadError() } + return "ok" + }) + guard let a = uploader.reserve() else { return XCTFail("reserve failed") } + await upload(uploader, a, "x") + XCTAssertTrue(uploader.hasFailures) + XCTAssertTrue(uploader.uploadedURLs.isEmpty) + + // performUpload is the same path retry() runs after re-loading the source. + await upload(uploader, a, "x") + XCTAssertEqual(uploader.uploadedURLs, ["ok"]) + XCTAssertFalse(uploader.hasFailures) + } + + func testResetClearsEverything() async { + let uploader = echoUploader() + guard let a = uploader.reserve() else { return XCTFail("reserve failed") } + await upload(uploader, a, "A") + uploader.reset() + XCTAssertTrue(uploader.attachments.isEmpty) + XCTAssertTrue(uploader.uploadedURLs.isEmpty) + XCTAssertEqual(uploader.remainingSlots, ComposeImageUploader.maxImages) + } + + func testIsUploadingWhileSlotReservedButNotYetComplete() { + let uploader = echoUploader() + _ = uploader.reserve() + XCTAssertTrue(uploader.isUploading) + XCTAssertTrue(uploader.uploadedURLs.isEmpty) + } +} diff --git a/blocker-prompts.md b/blocker-prompts.md deleted file mode 100644 index 18b59f6..0000000 --- a/blocker-prompts.md +++ /dev/null @@ -1,499 +0,0 @@ -# Blocker Prompts — InterlinedList API - -Actionable prompts for the backend team (or an AI coding assistant) describing -what the server needs to build or fix to unblock iOS App Store submission and v1 -feature completion. Ordered by urgency: - -- **Hard blockers** — App Store submission fails without these -- **v1 targets** — should ship with the first release -- **Quality improvements** — not blockers; fix before v1 if possible -- **Deferred** — post-v1; documented so the contracts are recorded - -Cross-references: `App-Store-Deployment.md` for the iOS-side feature phases; -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 ✅ 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. -Phase 14 in `App-Store-Deployment.md`. - -**Build:** - -`POST /api/messages/{id}/report` -``` -Request (camelCase JSON, Bearer auth): -{ - "reason": "spam" | "harassment" | "misinformation" | "inappropriate" | "other", - "detail": "optional free-text string" -} - -Response 200: -{ "reported": true } -``` -- Idempotent — a second report from the same user on the same message returns 200, not 409 -- Store reports server-side for moderation review (even if there's no moderation UI yet) - -`POST /api/users/{id}/report` -``` -Request: same shape as message report -Response: { "reported": true } -``` -- `{id}` is the user ID (not username) of the reported user -- Idempotent - -**Success criteria:** -- Both accept a valid Bearer token -- Both store the report persistently -- Neither throws 5xx when the same report is submitted twice -- Response is camelCase JSON - ---- - -### 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. - -**Build:** - -`POST /api/users/{id}/block` -``` -Request: empty body or {} -Response 200: { "blocked": true } -``` -- Idempotent (blocking an already-blocked user returns 200) -- Effect: blocked user's content is excluded from the caller's feed, - replies, search results, and notifications — server-side enforcement - is preferred; if not possible immediately, document a `blockedBy` - flag on messages/users so the iOS client can filter locally - -`DELETE /api/users/{id}/block` -``` -Response 200: { "blocked": false } -``` -- Idempotent (unblocking a non-blocked user returns 200) - -`GET /api/user/blocks` -``` -Response 200: -{ - "blockedUsers": [ - { - "id": "string", - "username": "string", - "displayName": "string", - "avatar": "url-string | null" - } - ], - "pagination": { "total": int, "limit": int, "offset": int, "hasMore": bool } -} -``` -- Accepts query params `?limit=` and `?offset=` for pagination -- All three endpoints require Bearer auth - ---- - -### 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 -cache clears. If this doesn't exist server-side, iOS will implement local-only -muting — document that decision so the iOS client knows which path to take. - -**Build (if implementing):** - -`POST /api/users/{id}/mute` → `{ "muted": true }` (idempotent) -`DELETE /api/users/{id}/mute` → `{ "muted": false }` (idempotent) -`GET /api/user/mutes` → same pagination shape as `/api/user/blocks` - -Semantics: muted users' content is hidden from the caller's feed and -notifications but the muted user is not told (unlike a block, which may -prevent them from interacting). - -**If not implementing:** reply with `404` on `/api/users/{id}/mute` and the -iOS client will fall back to local-only muting. - ---- - -### 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 -live URL to link to. Without a live URL the terms-acceptance checkbox cannot -be wired up, and the app fails review. - -**Build:** Create a publicly accessible page at one of: -- `https://interlinedlist.com/terms` -- `https://interlinedlist.com/guidelines` -- `https://interlinedlist.com/community` - -Requirements: -- Returns HTTP 200 with no login required -- States a zero-tolerance policy for objectionable content and abusive behaviour -- Is linkable from the iOS registration screen (a webview link at sign-up) and - from Settings → About - -**Alternative:** the iOS app can present Apple's standard EULA instead of a -custom page. Decide which path to take and communicate the URL (or the Apple -EULA decision) to the iOS team so they can wire `RegisterView`. - ---- - -## v1 TARGETS - -### 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`) -but their contracts are unconfirmed. The iOS `PushService` needs the exact -request/response shapes before it can be built. - -**Confirm:** - -`POST /api/push/register` -``` -Expected request (camelCase JSON, Bearer auth): -{ "token": "hex-device-token-string", "platform": "ios" } - -Expected response: -{ "registered": true } -``` - -`DELETE /api/push/unregister` -``` -Expected request: { "token": "hex-device-token-string" } - — or — no body if the server unregisters the stored token for the - authenticated user automatically -Expected response: { "unregistered": true } -``` - -**Also confirm:** -- Does the backend distinguish sandbox (TestFlight) vs production APNs - environment? If so, how does it determine which to use — does the iOS - client need to pass an `environment` field, or does the server infer it - from the key type? -- What is the push payload shape the server sends? The iOS client needs - the `actionUrl` field format to route tap events through the deep-link - handler. Expected shape: - ```json - { - "aps": { "alert": { "title": "…", "body": "…" }, "badge": 1, "sound": "default" }, - "actionUrl": "interlinedlist://…" - } - ``` - ---- - -### 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): -```json -{ - "content": "…", - "organizationId": "org-uuid-here" -} -``` -- `organizationId` is optional; omitting it means the authenticated user is - the author (existing behaviour) -- The caller must be `owner` or `admin` of the specified org; return 403 otherwise -- The created message's author metadata should be the organization (name, avatar, - slug) rather than the individual user -- The feed row and any reply context should show the org as author - -**If the field doesn't exist:** add it and document the exact field name and -casing (camelCase expected). If the feature is not planned, reply so the iOS -team can formally defer Phase 15. - ---- - -### 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 -the iOS app is Bearer-only, the Mastodon/cross-post identity picker is -permanently empty — the app cannot discover which social accounts the user -has connected, so cross-posting is effectively blind. - -**Fix (choose one):** - -**Option A — Fix auth on the existing endpoint:** -Update `GET /api/user/identities` to accept a valid Bearer token the same -way `GET /api/user` does. No response shape change needed. - -Expected response (confirm current shape): -```json -{ - "identities": [ - { - "provider": "mastodon", - "handle": "@user@techhub.social", - "instanceUrl": "https://techhub.social", - "connectedAt": "2026-01-01T00:00:00Z" - }, - { - "provider": "bluesky", - "handle": "@user.bsky.social", - "connectedAt": "2026-01-01T00:00:00Z" - } - ] -} -``` - -**Option B — Embed identities in GET /api/user:** -Add a `connectedIdentities` (or `identities`) array to the `GET /api/user` -response so a single authenticated call returns both user data and linked -providers. The iOS app already calls `/api/user` on login — bundling it -there eliminates the extra round-trip. - ---- - -## QUALITY IMPROVEMENTS - -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. 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). - -**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:** 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 -{ - "organizations": [ - { - "id": "…", - "name": "…", - "slug": "…", - "userRole": "owner" | "admin" | "member", - "memberCount": 3, - … - } - ] -} -``` - ---- - -### 9. Standardize pagination block on watcher list - -**Why:** `GET /api/lists/{id}/watchers/users` returns `total` at the -top level instead of inside the `pagination` object. Every other endpoint -puts it inside `pagination`. This inconsistency broke the iOS decoder once. - -**Fix:** Move `total` inside the `pagination` block: -```json -{ - "watchers": […], - "pagination": { "total": 123, "limit": 20, "offset": 0, "hasMore": true } -} -``` - ---- - -### 10. Add role to GET /api/lists/{id}/watchers/me - -**Why:** The endpoint returns only `{ "watching": bool }`. A non-owner -watcher can't learn their own role (watcher / collaborator / manager) -without fetching the full watcher list, which may be manager-gated. This -limits iOS's ability to show role-specific UI to list collaborators. - -**Fix:** -```json -{ "watching": true, "role": "collaborator" } -``` - ---- - -### 11. Return the updated user from avatar endpoints - -**Why:** `POST /api/user/avatar/upload` and `POST /api/user/avatar/from-url` -return only `{ "url": "…" }`. The iOS app must issue a follow-up `GET /api/user` -to refresh the avatar everywhere, adding a round-trip after every avatar change. - -**Fix:** Return the full updated user object alongside the URL: -```json -{ "url": "https://…", "user": { …full user object… } } -``` - ---- - -### 12. Unify link metadata shape between feed and metadata endpoint - -**Why:** Feed message objects expose link metadata as: -```json -linkMetadata.links[].metadata.thumbnail / .title / .description -``` -But `POST /api/messages/{id}/metadata` returns: -```json -metadata.links[].image / .title / .description -``` -Different wrapping, different key names (`thumbnail` vs `image`). iOS models -both shapes separately. One consistent schema would remove a whole decode path. - -**Fix:** Standardize to the feed shape (nested `metadata` object with -`thumbnail`) since it is the primary read path. Update the `POST /{id}/metadata` -response to match. - ---- - -### 13. Confirm POST /api/lists/{id}/watchers self-watch semantics - -**Why:** The `POST /api/lists/{id}/watchers` body is `{ userId, role }` and -it's unclear whether `userId` is required for self-watch or whether omitting -it defaults to the authenticated user. The public "Watch this list" CTA in -iOS sends its own `userId` with `role: "watcher"` as a workaround. - -**Clarify:** Is `userId` required for self-watch, or does the server default -to the authenticated caller? Document the self-watch path explicitly so the -iOS client doesn't need to send its own ID. - ---- - -### 14. Confirm cross-post failure reporting shape - -**Why:** Successful cross-posts return `crossPostUrls: [...]` on the created -message (confirmed). It's unconfirmed how the API signals a *failed* -cross-post. Does a failed platform appear in `crossPostUrls` with an error -field, get omitted entirely, or surface in a separate array? - -**Clarify:** Document the failure path so the iOS toast can show ✓/✗ -per platform rather than just listing successful destinations. - ---- - -## DEFERRED — post-v1 - -Document these now so the contracts are on record; build after the first -App Store submission. - -### 15. Bearer auth for GitHub integration (/api/github/*) - -**Why:** `/api/github/*` requires session-cookie auth and rejects Bearer -tokens. iOS is Bearer-only. GitHub integration (Phase 11) is fully deferred -until this is resolved. - -**Options:** -- Add Bearer token support to `/api/github/*` endpoints directly -- Implement `POST /api/auth/github/session` that exchanges a valid Bearer - token for a short-lived session scoped only to GitHub endpoints - -Do not add a cookie jar to the iOS client — it bypasses the Bearer security -model. - ---- - -### 16. Tag discovery endpoints - -**Why:** Tag filtering (`?tag=`) works but there's no discovery or autocomplete -path. Phase 13b (tag explorer + `#` autocomplete in Compose) is blocked on -these. - -**Build:** - -`GET /api/tags/trending` -```json -{ "tags": [ { "tag": "string", "count": int } ] } -``` - -`GET /api/tags/autocomplete?q=fo` -```json -{ "tags": ["foo", "football", "food"] } -``` - ---- - -### 17. Realtime feed / notification updates (WebSocket or SSE) - -**Why:** Everything is pull-only today. APNs (Phase 9) covers the highest-value -push events. Realtime is a polish feature — new-post banners without a full -refresh, live notification badge updates. - -**Build:** A WebSocket endpoint or SSE stream authenticated with Bearer token. -Events needed at minimum: -- `new_message` — a new feed item is available (client shows "New posts" banner) -- `notification` — a new notification arrived (client refreshes badge count) - -Document the event envelope and auth handshake so Phase 17 can be scoped. - ---- - -### 18. Document delta sync endpoint for offline documents - -**Why:** Phase 16 (offline document sync) needs server support to -push/pull document changes efficiently. - -**Build:** - -`GET /api/documents/sync?lastSyncAt=ISO8601` -```json -{ - "documents": [ …documents changed or deleted since lastSyncAt… ], - "deletedIds": ["id1", "id2"], - "serverTime": "ISO8601" -} -``` - -`POST /api/documents/sync` -``` -Request: { "edits": [ { "id": string, "content": string, "clientUpdatedAt": ISO8601 } ] } -Response: { "merged": [ …resolved docs… ], "conflicts": [ …docs where server won… ] } -``` - -Conflict resolution: last-write-wins per document (server clock wins on tie). -Conflicted docs should be returned so the iOS client can show a banner. - ---- - -## Confirmed gaps for reference - -Last audit: 2026-07-07. - -| Ref | Gap | iOS workaround | Status | -|---|---|---|---| -| 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 (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 (unverified) | diff --git a/docs-gap.md b/docs-gap.md new file mode 100644 index 0000000..04fd8da --- /dev/null +++ b/docs-gap.md @@ -0,0 +1,289 @@ +# API Docs ↔ Implementation Gaps — for the interlinedlist.com site / API team + +**Prepared:** 2026-07-18 +**By:** the InterlinedList **iOS** client team +**Sources:** +1. The shipped iOS client's `APIClient.swift` — a production HTTP client that + actually calls these endpoints. +2. The public docs at `https://interlinedlist.com/help/api/*` (all detail pages + read verbatim on 2026-07-18). +3. **Live read-only probes** against production on 2026-07-18 using the + `messenger@interlinedlist.com` test account (a **subscriber**). Probes were + GETs, `OPTIONS` (for `Allow`-header verb detection), and the login POST only — + **no data was mutated.** + +> Because we ran live probes, most items below are now **CONFIRMED** rather than +> "reconcile." Each finding is tagged with who owns the fix: +> **[DOC]** docs are wrong/incomplete · **[BACKEND]** the API blocks the mobile +> client (Bearer rejected) · **[iOS]** the client is wrong (we'll fix it; listed +> so you know your docs are right) · **[VERIFY]** still needs a write-test or a +> non-subscriber account. + +--- + +## 0. The one systemic finding + +**Several whole feature areas reject Bearer tokens and only accept a session +cookie.** The iOS app is **Bearer-only** (it has no cookie jar), so these +features are simply unreachable from mobile. Confirmed live (401 with a valid +Bearer) and/or stated in your own docs: + +| Area | Endpoint(s) | Bearer? | Consequence for iOS | +|---|---|---|---| +| CSV Exports | `GET /api/exports/*` | ❌ 401 (confirmed live) | Export feature is **dead** in the shipped app | +| GitHub | `GET/POST/PATCH /api/github/*` | ❌ (docs: "not accepted") | GitHub-backed lists & issues **cannot be built** for iOS | +| LinkedIn targets | `GET/PUT /api/linkedin/posting-targets` | ❌ 401 (confirmed live) | LinkedIn org/target picker **cannot be built** for iOS | + +**The single highest-value thing you can do for mobile parity is add Bearer-token +support to these endpoints** (see Prompt A). Everything else below is smaller. + +--- + +## 1. Findings at a glance (all CONFIRMED unless noted) + +| # | Owner | Finding | Evidence | +|---|---|---|---| +| F1 | **BACKEND** | `GET /api/exports/*` rejects Bearer (session-only) | Live: 401 w/ valid Bearer + no-auth | +| F2 | **BACKEND** | `GET/PUT /api/linkedin/posting-targets` rejects Bearer | Live: 401; docs say "Auth: Session" | +| F3 | **BACKEND** | All `/api/github/*` reject Bearer | Docs: "Bearer tokens are not accepted" | +| F4 | **DOC** | Messages page **auth column is unreliable** — `/api/messages/:id/replies` is marked "Session" but returns **200 with no auth at all** | Live: 200 Bearer + 200 no-auth | +| F5 | **DOC** | **No Moderation docs section exists**, but report/block/mute are live | Live: `GET /api/user/blocks` & `/mutes` → 200 (Bearer) | +| F6 | **DOC** | `POST /api/user/organizations` is documented as "Create new organization" but the client (and, we believe, the product) uses it to **join** an existing org | Client sends `{organizationId}`; docs say "create" | +| F7 | **DOC** | Document **folder path-scoping** has no warning — root routes silently ignore folders | Docs omit the caveat; causes silent data-loss | +| F8 | **iOS** | Client uses `POST /api/user/update`; server allows **only `PATCH`** → profile/settings/avatar-URL writes 405 | Live OPTIONS `Allow: OPTIONS, PATCH` | +| F9 | **iOS** | Client uses `PUT /api/messages/:id` to edit; server allows **`PATCH`, not `PUT`** → edit 405 | Live OPTIONS `Allow: DELETE, GET, HEAD, OPTIONS, PATCH` | +| F10 | **iOS** | Client uses `PUT /api/notifications/:id/read`; server allows **only `PATCH`** → mark-read 405 | Live OPTIONS `Allow: OPTIONS, PATCH` | +| F11 | **iOS** | Client sends Bearer to exports (see F1) → export is dead | Live 401 | +| F12 | **VERIFY** | Push body field: docs say **`deviceToken`**, client sends **`token`** → likely silent push failure | Not write-tested; verb/path confirmed correct | +| F13 | **VERIFY** | `POST /api/documents` documented **Subscriber-only**; iOS assumes documents are **free** | Test account is a subscriber; couldn't observe free-user path | +| F14 | **DOC** | `crossPostResults[].platform` can be omitted (crashed a strict client decoder) | Client patched it to optional | + +--- + +## 2. [BACKEND] Endpoints that reject Bearer and block the mobile client + +### F1 — CSV Exports are session-only (confirmed) +``` +GET /api/exports/messages [Bearer] -> 401 {"error":"Unauthorized"} +GET /api/exports/lists [Bearer] -> 401 +GET /api/exports/follows [Bearer] -> 401 +GET /api/exports/list-data-rows [Bearer] -> 401 +GET /api/exports/messages [no auth] -> 401 +``` +Your docs correctly state exports don't accept Bearer — so **the docs are right +and the iOS export feature is broken** (it sends Bearer and always 401s). To make +export work on mobile, **add Bearer support to `/api/exports/*`**. (Separately, +your docs list a `list-data-rows` export the client doesn't know about — we may +add it once auth works.) + +### F2 — LinkedIn posting-targets are session-only (confirmed) +``` +GET /api/linkedin/posting-targets [Bearer] -> 401 +``` +Docs (`/help/api/linkedin-integration`) confirm "Auth: Session" for +`/api/linkedin/targets`, `/api/linkedin/posting-targets` (GET+PUT), and +`/api/linkedin/sync-pages`. The composer's `linkedInTargets` field on +`POST /api/messages` already works, but iOS **can't fetch the target list** to +populate a picker. **Add Bearer support to the LinkedIn targets endpoints** and +the iOS target picker becomes buildable. (Thanks for documenting the response +shape — `{targets:[{kind, label, pageId|personalPageId, linkedInPageId, +enabled}], orgScopeMissing}` — and the `pageId`/`personalPageId` → `linkedInTargets` +mapping. That's exactly what we need.) + +### F3 — GitHub integration is session-only +Docs (`/help/api/github-integration`): every `/api/github/*` endpoint requires +"a session cookie (Bearer tokens are not accepted)" plus a linked GitHub +identity, and these "power features like GitHub-backed lists." GitHub-backed +lists are a headline web feature iOS can't reach at all. **Add Bearer support to +`/api/github/*`** to unblock a native GitHub-backed-lists experience. + +--- + +## 3. [DOC] Documentation fixes + +### F4 — Audit the Messages page auth column (it's demonstrably wrong) +`/help/api/messages` marks `GET /api/messages/:id/replies`, `POST /:id/dig`, +`DELETE /:id/dig`, and `PATCH /:id` as **Session**. But live: +``` +GET /api/messages/:id/replies [Bearer] -> 200 +GET /api/messages/:id/replies [no auth] -> 200 (it's effectively public for public messages) +``` +So the "Session" label is wrong for replies. Please re-audit the auth column for +the whole Messages page against the actual middleware — where Bearer works, say +"Session or Bearer"; where an endpoint is public, say "Public." (`dig`/`undig` +are writes we didn't probe, but given `replies` was mislabeled, treat the whole +column as suspect.) + +### F5 — Add a Moderation section (endpoints exist and are live) +There is **no `/help/api/moderation` page** and no moderation section in the +index. These are live and Bearer-accepted (Apple requires them for our app): +``` +GET /api/user/blocks?limit=1 [Bearer] -> 200 {"blockedUsers":[],"pagination":{...}} +GET /api/user/mutes?limit=1 [Bearer] -> 200 {"mutedUsers":[],"pagination":{...}} +``` +The client also uses (writes, not probed): `POST /api/messages/:id/report`, +`POST /api/users/:id/report`, `POST|DELETE /api/users/:id/block`, +`POST|DELETE /api/users/:id/mute`. Please document all of them (paths, bodies — +reports take `{reason, detail?}` — auth, response shapes). + +### F6 — Clarify `POST /api/user/organizations`: create vs. join +`/help/api/users-and-profile` documents `POST /api/user/organizations` as +**"Create new organization."** The iOS client calls it with `{ organizationId }` +to **join an existing org** (it creates orgs via `POST /api/organizations`). +Please clarify the real semantics: does `POST /api/user/organizations` create, +join, or branch on the body? If join is a different route, tell us. Also +`GET /api/user/organizations` (list my orgs) — confirmed live 200 — is +documented here now; good. + +### F7 — Add a folder path-scoping warning to Documents / Document Folders +The Document Folders page documents `POST`/`GET +/api/documents/folders/:id/documents`, but neither page warns that: +- `GET /api/documents` returns **only root docs** and **ignores `?folderId`**. +- `POST /api/documents` **always creates at root** (no `folderId` field). +- Only `PATCH /api/documents/:id` accepts `folderId` (to move). + +Using the root routes for folder content **silently drops docs to root**. A +prominent callout on both pages would save every future integrator this bug. + +### F14 — Document the `crossPostResults` shape (mark `platform` optional) +On `POST /api/messages` with cross-posting, response `crossPostResults[]` entries +sometimes **omit `platform`** (observed with Bluesky), which crashed a strict +decoder. Please document the shape and which fields are optional, or always emit +`platform`. + +--- + +## 4. [iOS] Verb/auth mismatches we will fix (your docs are right — listed for awareness) + +These are **client bugs** confirmed via live `OPTIONS` `Allow` headers. We're +fixing them on our side. They're here so you know (a) your docs are correct and +(b) *if* you'd rather accept the client's verb too, that's an option. + +| Endpoint | Server allows | Client sends | Result | Our fix | +|---|---|---|---|---| +| `/api/user/update` | `OPTIONS, PATCH` | `POST` | 405 → profile/settings/avatar-URL writes fail | switch to `PATCH` | +| `/api/messages/:id` (edit) | `…, PATCH` (no `PUT`) | `PUT` | 405 → message edit fails | switch to `PATCH` | +| `/api/notifications/:id/read` | `OPTIONS, PATCH` | `PUT` | 405 → mark-read fails | switch to `PATCH` | +| `/api/exports/*` | session cookie only | Bearer | 401 → export dead | needs your F1 fix | + +> If you'd like to reduce client breakage generally, accepting **both** `POST` +> and `PATCH` on `/api/user/update`, and **both** `PUT` and `PATCH` on the edit +> routes, would make the API more forgiving — but it's not required; we'll align +> the client regardless. + +--- + +## 5. [VERIFY] Two items we couldn't confirm read-only + +### F12 — Push registration body field (`token` vs `deviceToken`) +Path and verb are correct (`POST /api/push/register`, `DELETE +/api/push/unregister` — confirmed via `Allow`). But your docs show the body field +as **`deviceToken`** (plus `platform`, optional `appId`) while the client sends +**`token`**. If the handler reads `deviceToken`, iOS device tokens are being +**silently dropped** (no error, no push). We didn't write-test this. Please +either confirm the handler field name (we'll rename the client) or accept `token` +as an alias. This is the highest-impact silent-failure candidate remaining. + +### F13 — Is document creation subscriber-only or free? +`/help/api/documents` marks `POST /api/documents` (and image upload, and +template creation) **Subscriber only**. The iOS product direction assumes +**documents are free**. Our test account is a subscriber, so we couldn't observe +the free-user path. Please confirm the real gate. If creation is subscriber-only, +free iOS users silently can't create docs and we must hide that UI; if it's free, +drop the "Subscriber only" label. + +--- + +## 6. Confirmed-CORRECT (please do NOT "fix" these) + +Live-verified that docs and client already agree — flagged so nothing here gets +changed by mistake: +- `POST /api/user/delete` (account deletion) — `Allow: OPTIONS, POST` ✓ +- `POST /api/user/avatar/from-url`, `POST /api/user/avatar/upload` ✓ +- `POST /api/notifications/mark-all-read` ✓; `GET /api/notifications` requires + `scope=tray` (400 without) ✓ +- `GET /api/user/notification-preferences` + `PATCH` ✓ +- `GET /api/user/identities`, provider `status` for LinkedIn/Twitter only ✓ +- Public-browse namespace split is **real and load-bearing** (do not "normalize"): + - `GET /api/user/:username/messages` (singular `user`) → 200; the plural form → **404** + - `GET /api/users/:username/lists` / `/lists/:id/data` / `/documents` (plural `users`) → 200; the singular form → **404** + - Recommend documenting this split explicitly — it's a footgun even though it's intentional. +- `GET /api/documents/templates`, `GET /api/documents/sync` — live 200 over Bearer ✓ + +--- + +## 7. Ready-to-paste prompts for the site/API team's Claude + +### Prompt A — Add Bearer support to the session-only feature areas (highest value) +> Our iOS app authenticates with Bearer tokens only (no session cookie). Live +> probing on 2026-07-18 confirmed these return 401 to a valid Bearer: +> `GET /api/exports/{messages,lists,follows,list-data-rows}` and +> `GET /api/linkedin/posting-targets`; and the docs state `/api/github/*` also +> reject Bearer. In the API, extend the auth middleware for `/api/exports/*`, +> `/api/linkedin/*`, and `/api/github/*` to accept `Authorization: Bearer ` +> the same way `/api/messages` and `/api/user` already do. If any must stay +> session-only for a security reason, document that explicitly and tell us so we +> can drop those features from mobile. Then update each page's Auth column. + +### Prompt B — Fix the Messages auth column and add a Moderation section +> 1. On `/help/api/messages`, the Auth column is wrong: `GET /api/messages/:id/replies` +> is marked "Session" but returns 200 with a Bearer token AND with no auth at +> all. Re-audit every row against the actual middleware and correct the column +> ("Session or Bearer" / "Public" as appropriate), especially `replies`, `dig`, +> `undig`, and `PATCH /:id`. +> 2. Add a **Moderation** docs page. These are live: `GET /api/user/blocks`, +> `GET /api/user/mutes`, `POST /api/messages/:id/report`, +> `POST /api/users/:id/report`, `POST|DELETE /api/users/:id/block`, +> `POST|DELETE /api/users/:id/mute`. Document paths, bodies (reports take +> `{reason, detail?}`), auth, and response shapes, and link it from the index. + +### Prompt C — Clarify org-join and add the doc-folder warning +> 1. `/help/api/users-and-profile` documents `POST /api/user/organizations` as +> "Create new organization," but our client posts `{ organizationId }` to it to +> JOIN an existing org (it creates via `POST /api/organizations`). Clarify the +> real behavior of `POST /api/user/organizations` (create vs join vs +> body-dependent) and document the canonical join route. +> 2. On `/help/api/documents` and `/help/api/document-folders`, add a prominent +> warning: `GET /api/documents` ignores `?folderId` and `POST /api/documents` +> always writes to root; to create/list inside a folder you MUST use +> `/api/documents/folders/:id/documents`. Using the root routes silently drops +> documents to root. + +### Prompt D — Confirm two ambiguous contracts +> 1. Push: does the `POST /api/push/register` / `DELETE /api/push/unregister` +> handler read the body field `deviceToken` or `token`? Our iOS client sends +> `token`; your docs show `deviceToken`. If it reads `deviceToken`, our tokens +> are silently dropped — either accept `token` as an alias or tell us to rename. +> 2. Documents: is `POST /api/documents` truly subscriber-only (as the docs say) +> or free? It changes whether our iOS app must hide document creation from +> free users. Confirm against the handler. + +### Prompt E — Document the crossPostResults response shape +> On `POST /api/messages` with cross-posting, document the exact shape of +> `crossPostResults[]` and mark optional fields — in particular `platform` is +> sometimes omitted (observed for Bluesky), which crashed a strict client +> decoder. Either always include `platform` or document it as optional. + +--- + +## 8. Coverage & method notes + +**Docs pages read verbatim (2026-07-18):** `/help/api` and all detail pages — +`authentication`, `users-and-profile`, `public-profiles`, `messages`, +`following`, `lists`, `list-folders`, `documents`, `document-folders`, +`notifications`, `push-notifications`, `exports`, `organizations`, +`github-integration`, `linkedin-integration`, `utility-endpoints`, +`administration`. (There is **no** `moderation` page — that's finding F5.) + +**Live probe (read-only) on 2026-07-18, account `messenger` (subscriber):** +login (POST sync-token), ~30 GETs, and `OPTIONS` verb-detection on 15 routes. +No writes were performed. + +**Not tested (would require writes or a different account):** push body-field +behavior (F12), the create-vs-join semantics of `POST /api/user/organizations` +(F6), the free-user document-creation gate (F13), and `dig`/`undig` auth (F4). +We're happy to run authorized write-tests for any of these if useful. + +**Recommendation:** diff this report against your OpenAPI/route table. The +client-contract items (Section 4) and the Bearer-rejection items (Section 2) are +where a real, shipped consumer already diverges from the platform today. diff --git a/feature-gaps.md b/feature-gaps.md new file mode 100644 index 0000000..607bf51 --- /dev/null +++ b/feature-gaps.md @@ -0,0 +1,315 @@ +# Feature Gaps — InterlinedList iOS vs. interlinedlist.com + +**Assessed:** 2026-07-18 (updated with live API probe results the same day) +**Repo:** `interlinedlist-ios` (the SwiftUI iOS client) +**Compared against:** the live web product at `https://interlinedlist.com` — its +landing page, `/help`, and `/help/api/*` detail pages — plus this repo's own +`App-Store-Deployment.md` and the actual `APIClient`/Views code, **and live +read-only probes of the production API on 2026-07-18** (test account +`messenger@interlinedlist.com`, a subscriber). + +> Scope note: this is the **iOS** app. The web site lists an "iOS App (Coming +> Soon)" and a separate "macOS App (Coming Soon)"; a native macOS target does not +> exist in this repo. Everything below is about closing the iOS↔web gap. + +## TL;DR + +The iOS app is **close to full v1 parity** for the core product, BUT live +probing revealed **four shipped features that are currently broken** because the +client uses the wrong HTTP verb or an unsupported auth mode (Section 0). **Fix +those first** — they're regressions, not missing features. After that, the real +parity gaps are a defined set of advanced/integration features (Section 2), three +of which are **backend-blocked** because the endpoints reject Bearer tokens +(GitHub, LinkedIn targets, exports — see the companion `docs-gap.md`). + +--- + +## Section 0 — Confirmed defects to fix first (found via live probe) + +These were confirmed on 2026-07-18 by reading the production API's `OPTIONS` +`Allow` headers and by direct GETs. Each is a **client bug** (or a +backend-auth blocker) in already-shipped functionality. Verify each in-app, then +fix. Full cross-team detail is in `docs-gap.md` (F8–F13). + +| # | Symptom | Root cause | Fix | +|---|---|---|---| +| D1 | Editing profile / theme / default-visibility / avatar-from-URL silently fails | `APIClient` calls `POST /api/user/update`; server allows **only `PATCH`** (405). Also sends snake_case; server expects camelCase | Switch the three `/api/user/update` calls to `patchCamel` | +| D2 | Editing a posted message fails | `editMessage` uses `PUT /api/messages/:id`; server allows **`PATCH`, not `PUT`** (405) | Switch `editMessage` to `patchCamel` (camelCase `{content, publiclyVisible}`) | +| D3 | Marking a notification read fails | `markNotificationRead` uses `PUT /api/notifications/:id/read`; server allows **only `PATCH`** (405) | Switch to a `PATCH` request | +| D4 | CSV export always fails | `exportCSV` sends a Bearer token; `/api/exports/*` is **session-cookie-only** (401) | Backend-blocked — needs Bearer support server-side (`docs-gap.md` F1). Until then, hide the Export UI | +| D5 | Push may never arrive | `registerPushDevice` sends body `{token}`; docs specify `{deviceToken}` | Verify handler field name; likely rename `token` → `deviceToken` (`docs-gap.md` F12) | + +**Prompt for Claude — fix D1/D2/D3 (verb + casing):** +> In `InterlinedList/Services/APIClient.swift`, three writes use the wrong HTTP +> method (confirmed against production via `OPTIONS` Allow headers): +> 1. `updateProfile`, `updateUserSettings`, and `applyAvatarUrl` all call +> `post("/api/user/update", …)`. The server allows **only `PATCH`** on that +> route and expects a **camelCase** body. Change them to use `patchCamel` +> (add a `patchCamel` call path if needed — it already exists). Keep the +> `{user?}`-unwrapping response handling. +> 2. `editMessage` calls `put("/api/messages/:id", …)`; the server allows +> `PATCH` not `PUT`. Change it to `patchCamel` with a camelCase body +> `{ content, publiclyVisible }`. +> 3. `markNotificationRead` calls `put("/api/notifications/:id/read", …)`; the +> server allows only `PATCH`. Change it to a `PATCH` request (empty body). +> Update the corresponding `MockURLSession` unit tests to assert `PATCH` and the +> camelCase body keys. Do NOT change `patchScheduledMessage` (already correct). +> After the change, smoke-test in the simulator: edit profile, edit a message, +> mark a notification read. + +**Prompt for Claude — D4 (exports) + D5 (push):** +> 1. Exports: `exportCSV` in `APIClient` sends a Bearer token, but `/api/exports/*` +> rejects Bearer (401, confirmed live) — it's session-cookie-only. Until the +> backend adds Bearer support (tracked in `docs-gap.md` F1 / `blocker-prompts.md`), +> **hide the Export entry point** in the UI so users don't hit a dead feature, +> and leave a `// TODO: re-enable when /api/exports accepts Bearer` note. +> 2. Push: `registerPushDevice`/`unregisterPushDevice` send `{ token }`; the API +> docs specify `{ deviceToken }`. Confirm the handler field (ask backend or +> write-test), then rename the client field to `deviceToken` if needed and add +> a unit test asserting the body key. This is a silent-failure path (no push, +> no error), so prioritize confirming it. + +--- + +## Section 1 — How to read the parity gaps + +Compose, feed, lists (with structured schema editing), documents (with inline +images and folders), follow graph, organizations, notifications, moderation, +push, and settings are all shipped and wired. What remains are advanced / +integration features. Each gap in Section 2 has: what the web does, iOS status, +**backend readiness (now largely confirmed by live probe)**, and a ready-to-paste +Claude prompt. + +### Conventions every prompt assumes (from `CLAUDE.md`) + +- **Encoders:** `get` / `postCamel` / `putCamel` / `patchCamel` for camelCase + bodies (most endpoints); `post`/`put`/`patch` for snake_case. Check the + existing method before adding one — mismatches fail **silently** server-side. +- **401 contract:** never log out on a feature-endpoint 401; route through + `authState.handleUnauthorized()` (re-validates against `GET /api/user`). +- **Subscriber gating = hide, never disable/paywall.** Gate on + `authState.user?.isSubscriber == true`. **No** billing/upgrade/price copy and + **no** link to interlinedlist.com to pay (App Store Guideline 3.1.1). +- **Standards:** no comments unless the "why" is non-obvious; no force-unwrap; + `@MainActor` over `DispatchQueue.main.async`; `.accessibilityLabel` on every + control; a `#Preview` in every View file. +- **New files must be manually registered** in `project.pbxproj` (no synced + groups) — use the `xcodeproj` Ruby gem. +- **Tests:** add `MockURLSession` unit tests for every new/changed `APIClient` + method (assert method, path, encoder casing, decode of happy + error paths). + +--- + +## Section 2 — Parity matrix + +Legend: ✅ at parity · ◑ partial · ❌ missing · 🔴 broken · — n/a + +| Web capability | iOS | Notes | +|---|---|---| +| Email/password + OAuth (Mastodon, Bluesky, LinkedIn, Twitter) | ✅ | GitHub sign-in intentionally hidden | +| Feed, link previews, dig, reply, delete, search | ✅ | | +| **Edit a posted message** | 🔴 | D2 — uses unsupported `PUT` (405) | +| Compose: image/video, scheduled, cross-post, repost, post-as-org | ✅ | | +| **Edit profile / settings / avatar-from-URL** | 🔴 | D1 — uses unsupported `POST` (405) | +| **Mark notification read** | 🔴 | D3 — uses unsupported `PUT` (405) | +| Lists: CRUD, folders, schema editor, rows, connections, watchers | ✅ | | +| Documents: CRUD, folders, search, inline images, public reader | ✅ | reads confirmed live; free-user create gate unverified (D/§2.9) | +| Follow graph, organizations, notifications (tray/prefs), moderation, push | ✅ | push field name unverified (D5) | +| **CSV exports** | 🔴 | D4 — `/api/exports/*` rejects Bearer (session-only) | +| **GitHub-backed lists / GitHub issues** | ❌ | §2.1 — backend-blocked (Bearer rejected, confirmed) | +| **Tag discovery / trending** | ◑ | §2.2 — can filter by tag; no discovery UI; endpoint unconfirmed | +| **LinkedIn org/target picker** | ◑ | §2.3 — backend-blocked (targets are session-only, confirmed) | +| **Document templates** | ❌ | §2.4 — endpoint confirmed live over Bearer; ready to build | +| **Content deep links / universal links** | ❌ | §2.5 — only auth callbacks routed | +| **Multi-account switching** | ❌ | §2.6 — web has `/api/auth/accounts`,`/switch`,`/remove-account`; iOS single-account | +| **Realtime updates** | ❌ | §2.7 — no realtime endpoint found; poll/refresh only | +| **Offline document sync** | ❌ | §2.8 — `/api/documents/sync` exists & is Bearer-accessible (confirmed); buildable | +| Utility widgets (weather/geolocation) | ❓ | §2.9 — confirm if user-facing list column types | +| Admin console | — | not an app feature | +| Subscription/billing UI | — | web-only by design; must never appear on iOS | + +--- + +## Section 2 — The gaps, prioritized + +### Tier A — Ready to build now (backend confirmed working over Bearer) + +#### 2.4 Document templates `Small` ✅ backend-ready +**Confirmed live:** `GET /api/documents/templates` returns +`{ folderCreated, templatesFolderId, templates:[{id,title,…}] }` over Bearer +(200). Web also has `POST /api/documents/from-template` (subscriber-only) and +`POST /api/documents/templates/seed-defaults`. + +**iOS status:** ❌ new documents start blank. + +**Prompt for Claude:** +> Add `APIClient.documentTemplates()` returning a `[DocumentTemplate]` model +> (fields from `GET /api/documents/templates`: `id`, `title`, and content/body — +> inspect the live response for the exact keys). In the create-document flow in +> `DocumentsView`, add an optional "Start from template" picker that prefills the +> new document's title/content. If you also wire `POST /api/documents/from-template`, +> gate it on `isSubscriber` (it's a subscriber endpoint) and hide it otherwise. +> Add `MockURLSession` tests and a `#Preview`. + +#### 2.5 Content deep links / universal links `Small–Medium` +Unchanged from prior assessment. `InterlinedListApp.handleDeepLink` only routes +`reset-password`, `verify-email`, `verify-email-change`, `oauth` +(`InterlinedListApp.swift:62–74`). No content permalinks, no Universal Links. + +**Prompt for Claude:** +> Extend `AppRouter` + `InterlinedListApp.handleDeepLink` to route content +> permalinks: user profiles, public lists (`/api/users/:username/lists/:id` → +> `PublicListDetailView`), public documents, and single message threads. Support +> both `interlinedlist://…` and `https://interlinedlist.com/…` via `.onOpenURL`. +> Note the confirmed public-browse namespace split: messages are +> `/api/user/:username/messages` (singular) while lists/documents are +> `/api/users/:username/…` (plural) — the wrong one 404s. Universal Links also +> need the backend to publish `apple-app-site-association` + the Associated +> Domains entitlement (capture in `blocker-prompts.md`); the custom-scheme path +> works without it. Add a share action (web permalink) on message/list/profile. + +#### 2.8 Offline document sync `Large` ✅ backend-ready (contract exists) +**Confirmed live:** `GET /api/documents/sync` returns +`{ folders:[…], … }` over Bearer (200) — the same delta-sync contract the web's +`il-sync` CLI uses. So mobile offline sync is **buildable** (not backend-blocked). + +**Prompt for Claude:** +> Inspect the full `GET /api/documents/sync` and `POST /api/documents/sync` +> contracts against production (revision/`updatedAt` fields, delta vs full-body, +> conflict signals) and record in `blocker-prompts.md`. Then design (and land a +> minimal slice of) offline document editing: cache edits in `DataCache`, replay +> via the sync endpoint on reconnect, resolve conflicts last-writer-wins or with +> a simple prompt. Feature-flag it. This is large — ship the read-then-queue +> slice first, then two-way sync. + +### Tier B — Backend-blocked (endpoints reject Bearer; need a backend change first) + +#### 2.1 GitHub-backed lists & GitHub integration `Large` ⛔ BACKEND (confirmed) +**What the web does:** lists are "local or **GitHub-backed**"; `/api/github/*` +covers repos, issues, labels, assignees. +**Confirmed blocker:** the GitHub docs state every `/api/github/*` endpoint +requires a **session cookie — "Bearer tokens are not accepted."** The iOS app is +Bearer-only, so **none of this is reachable** until the backend adds Bearer +support (see `docs-gap.md` Prompt A / F3). This is the biggest single parity gap. + +**Prompt for Claude:** +> Do NOT build UI yet — this is backend-blocked. Confirm with the backend owner +> when `/api/github/*` will accept Bearer tokens (tracked in `docs-gap.md` F3). +> Meanwhile, produce an implementation plan only: models + `APIClient` methods for +> `GET /api/github/repos` and `GET /api/github/issues?repo=owner/repo`, a repo +> picker in `CreateListView` for GitHub-backed lists, and a read-only issues view. +> Escalate the Bearer-auth requirement as the gating dependency. + +#### 2.3 LinkedIn org/target picker `Small` ⛔ BACKEND (confirmed) +**Confirmed:** `GET /api/linkedin/posting-targets` returns **401 to Bearer** +(session-only, per docs and live probe). The `linkedInTargets` field on +`POST /api/messages` already works; we just can't fetch the target list on mobile. +We *do* have the response shape now: +`{ targets:[{ kind: personal|orgPage|personalPage, label, pageId|personalPageId, +linkedInPageId, enabled }], orgScopeMissing }`, and posting uses `pageId` / +`personalPageId` in `linkedInTargets`. + +**Prompt for Claude:** +> Backend-blocked: `/api/linkedin/posting-targets` rejects Bearer. Once the +> backend adds Bearer support (`docs-gap.md` F2), add +> `APIClient.linkedInPostingTargets()` returning `[LinkedInTarget]` (kinds: +> personal/orgPage/personalPage; map `pageId`/`personalPageId` into the existing +> `linkedInTargets` request field). In `ComposeView`, when the LinkedIn toggle is +> on and the user is a subscriber, show a target picker + the existing "link as +> first comment" toggle. Hide for non-subscribers / no LinkedIn identity. Until +> Bearer works, leave the current default-destination behavior. + +#### 2.2 Tag discovery / trending `Small` ❓ needs discovery +"Hashtag organization and discovery" is marketed, but the client only supports a +`tag` filter (`messages(tag:)`), not a discovery surface. No trending-tags +endpoint is confirmed (not probed). + +**Prompt for Claude:** +> Probe for a trending/known-tags endpoint against production with the E2E `.env` +> token (`GET /api/tags/trending`, `/api/tags`, `/api/messages/tags`). Record the +> real path/shape in `blocker-prompts.md`. If one exists, add +> `APIClient.trendingTags()`, a model, a "Discover" surface in `FeedView`, and make +> in-body `#hashtags` tap through to the existing `tag:` feed. If none exists, +> document as backend-blocked and stop. Tests + `#Preview`. + +#### 2.7 Realtime updates `Large` ⛔ likely BACKEND +No realtime endpoint appears in the docs and none was probed. Poll/refresh only. + +**Prompt for Claude:** +> Discovery only: determine whether production exposes a realtime channel +> (`/api/stream`, `/api/events`, `/ws`, SSE `text/event-stream`). Record in +> `blocker-prompts.md`. If present, propose a `RealtimeService` (`@MainActor`, +> reconnect/backoff) that layers on the existing cache-then-refresh model. Build +> nothing until confirmed; feature-flag when built. + +### Tier C — Smaller / investigate + +#### 2.6 Multi-account switching `Small` +**What the web does:** the auth docs list `GET /api/auth/accounts`, +`POST /api/auth/switch`, `POST /api/auth/remove-account` (and `?all=true` logout) +— a cached multi-account switcher. iOS is single-account. + +**Prompt for Claude:** +> Confirm `GET /api/auth/accounts`, `POST /api/auth/switch`, +> `POST /api/auth/remove-account` accept Bearer (probe with the E2E token). If so, +> add an account switcher to `SettingsView`/profile: list cached accounts, switch +> active account (swap the Keychain token + `AuthState`), and remove an account. +> Keep it minimal and gated behind having >1 account. Tests + `#Preview`. + +#### 2.9 Document creation gate + utility widgets `Investigate` +- **Documents gate:** the docs mark `POST /api/documents` **subscriber-only**, but + the iOS product direction assumes documents are free. Our probe account is a + subscriber, so this is unverified. Test with a **non-subscriber** account: if + creation 403s for free users, hide the create-document UI for them; if it + succeeds, the docs are wrong (see `docs-gap.md` F13). +- **Utility widgets:** `/help/api/utility-endpoints` exists (weather, geolocation, + image-proxy, oauth-metadata). Investigate whether weather/geolocation are + user-facing **list column types** on the web; if so, `ListSchemaEditorView` is + missing those column types (a real lists-parity gap). Findings only this pass. + +--- + +## Section 3 — Housekeeping (doc drift) + +Two "deferred" phases actually shipped since `App-Store-Deployment.md` was last +updated (2026-07-07): + +| Phase | Doc says | Reality | +|---|---|---| +| **10 — Inline document image upload** | Tier 1, unchecked | ✅ Done (`uploadDocumentImage`; `DocumentsView.swift:692,766,785`) | +| **15 — Post as organization** | Tier 1, unchecked | ✅ Done (`postMessage(organizationId:)`; `ComposeView.swift:113`) | + +**Prompt for Claude:** +> In `App-Store-Deployment.md`, move Phase 10 and Phase 15 into "What's Already +> Shipped" (2026-07-10) and update the §0 "Not yet built" line. Also add the +> Section 0 defects (D1–D5) from `feature-gaps.md` to the tracking docs as +> **bugs**, since they affect already-shipped functionality. Docs only, no code. + +--- + +## Section 4 — Suggested execution order + +1. **Section 0 defects (D1–D5)** — these are broken shipped features (profile + edit, message edit, mark-read, export, push). Fix D1/D2/D3 immediately (one + `APIClient` PR), confirm D5 (push field), and hide the export UI (D4) until the + backend unblocks it. Highest priority. +2. **Backend asks** — file `docs-gap.md` Prompt A with the backend team: add + Bearer support to `/api/exports/*`, `/api/linkedin/*`, `/api/github/*`. This + unblocks D4 and features §2.1 and §2.3 in one move. +3. **Ready-now features** — §2.4 (document templates) and §2.5 (deep links); + then §2.8 (offline doc sync) since its contract is confirmed Bearer-accessible. +4. **After backend unblocks Bearer** — §2.1 (GitHub-backed lists, the biggest + parity win) and §2.3 (LinkedIn target picker). +5. **Discovery-dependent** — §2.2 (tags), §2.6 (multi-account), §2.7 (realtime), + §2.9 (doc gate + utility widgets). + +## Section 5 — Bottom line + +Core product is at parity **except for four shipped features that regressed to +broken** (Section 0) — fixing those is the immediate priority and mostly a +one-file `APIClient` change. Beyond that, full web parity needs: the +backend to **accept Bearer tokens on exports/GitHub/LinkedIn** (one systemic fix +that unblocks three features), then a handful of self-contained additions +(templates, deep links, multi-account, tag discovery) and two larger systems +(offline doc sync — now confirmed buildable; realtime — still needs a backend +endpoint). See `docs-gap.md` for everything that needs the site/API team.