Skip to content
Merged

Dev #12

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
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 */; };
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 */; };
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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -130,8 +134,10 @@
/* Begin PBXFileReference section */
09470364B5A0A559FDE3215F /* APIClientPushTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientPushTests.swift; sourceTree = "<group>"; };
0EF29F5DC946D80A63000209 /* FollowListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowListView.swift; sourceTree = "<group>"; };
104AAB22C8CEDBE47729581D /* ReorderSpikeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReorderSpikeView.swift; sourceTree = "<group>"; };
1147B978D9E72695E4457454 /* Moderation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Moderation.swift; sourceTree = "<group>"; };
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 = "<group>"; };
2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = "<group>"; };
2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDataStoreTests.swift; sourceTree = "<group>"; };
Expand All @@ -141,6 +147,7 @@
7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = "<group>"; };
78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessorTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
8B79D26B822DCA1188DF37E9 /* PushService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushService.swift; sourceTree = "<group>"; };
93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = "<group>"; };
98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -194,6 +201,7 @@
D0A1D0A1D0A1000A /* EmailVerificationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailVerificationBanner.swift; sourceTree = "<group>"; };
D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedIdentitiesView.swift; sourceTree = "<group>"; };
D0A1D0A1D0A1000E /* OAuthSignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSignInButton.swift; sourceTree = "<group>"; };
D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.swift; sourceTree = "<group>"; };
E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BlockedUsersView.swift; sourceTree = "<group>"; };
E5828367E38948582EF1DF2A /* WatchersListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchersListView.swift; sourceTree = "<group>"; };
ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -337,6 +345,7 @@
FC5914D730B4355F02E89D81 /* ILColor.swift */,
8B79D26B822DCA1188DF37E9 /* PushService.swift */,
5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */,
7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -380,6 +389,8 @@
2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */,
7800699C927CE3E133944E62 /* ReportSheet.swift */,
E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */,
104AAB22C8CEDBE47729581D /* ReorderSpikeView.swift */,
29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -455,6 +466,7 @@
T1E5T1E5T1E5P016 /* KeychainServiceTests.swift */,
4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */,
78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */,
D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */,
);
path = ServiceTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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;
};
Expand Down
165 changes: 165 additions & 0 deletions InterlinedList/Services/ComposeImageUploader.swift
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading