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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Guidance for Claude Code in this repo. Follow it exactly — it overrides defaul

- Pin a concrete simulator **UDID** — `name=iPhone 16` alone is ambiguous across runtimes (`xcrun simctl list devices`).
- **Parallelization is disabled** (`InterlinedList.xctestplan`, `parallelizable:false`): the E2E suite shares a static login token that parallel cloned sims break. `-parallel-testing-enabled NO` is reinforcement; keep the plan setting in sync.
- **Parallel worktrees must not share a simulator or DerivedData.** Two threads testing at once on the
same UDID kill each other's runner mid-suite; the survivor reports a bogus failure and may even run
the *other* worktree's test bundle. Give each worktree its own `-destination id=` **and**
`-derivedDataPath`.
- Unit tests stub HTTP via `MockURLSession` (`stub`/`enqueue`) — no network.
- E2E tests (`InterlinedListTests/E2E`) hit the **live** API, **read-only**; auto-`XCTSkip` unless `INTERLINEDLIST_EMAIL`/`INTERLINEDLIST_PASSWORD` are set (process env or a gitignored `.env`). Network-flaky — for a deterministic run add `-skip-testing:InterlinedListTests/E2EReadOnlyTests`.
- CI (`.github/workflows/ios.yml`) **builds only** (no tests) on push/PR to `main`, signing disabled.
Expand Down
20 changes: 20 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@
6CED0218B01DF6AA0FF9D3BF /* ListWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0470C00F1A0E215224120A1 /* ListWatcher.swift */; };
7201B78F48B60376E06DB08E /* ILColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5914D730B4355F02E89D81 /* ILColor.swift */; };
73777B938FC2AF9D06CE5AD4 /* APIClientGitHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */; };
745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */; };
79878E2A806CDB0B1659C7C5 /* MarkdownEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */; };
7CD66F2490C7D2D4A3227E6A /* ILWebURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489C4E80A2217A0B4D20B08A /* ILWebURLTests.swift */; };
7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADBF836F0B5BD6524776FE7 /* SharingView.swift */; };
874DE298749DF285984F1182 /* DirectMessageModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */; };
8BEDD4D904328EC8027314DC /* SpaceGrotesk.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */; };
945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236670B73F2B603E5EB318D7 /* ShareInvite.swift */; };
94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */; };
98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B096C366108083D174366 /* MarkdownBlockTests.swift */; };
99E688DB379BD4A824487604 /* ComposeImageUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */; };
9CA0B53AE87AA79D8BAC81FF /* FollowListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF29F5DC946D80A63000209 /* FollowListView.swift */; };
Expand Down Expand Up @@ -98,6 +100,7 @@
B1C1D1E1F1A1000B /* DocumentSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */; };
B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A1000E /* ListItemFormView.swift */; };
B270170D3EEF336A45464D01 /* CrossPostLinksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164718CC67967AC147E30465 /* CrossPostLinksView.swift */; };
C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A026223AD393B5E1A339E045 /* APIClient+Limits.swift */; };
C18C73C8A93572993F29D559 /* ImageUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */; };
C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; };
C478109CF3BB06C22E121904 /* ReportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7800699C927CE3E133944E62 /* ReportSheet.swift */; };
Expand All @@ -123,12 +126,14 @@
D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E7411CA219F3E8EB351BAB /* SharedListView.swift */; };
D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; };
DAC151BF93B6B223D43714F0 /* APIClientShareInvitesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */; };
DB762CA98AEE01D098940CED /* APIClientLimitsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */; };
DD652FF47D5509B976706914 /* DocumentSyncMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */; };
DDFD75240429AC8C22B4A828 /* APIClient+SharedLists.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */; };
DF75ECCB31B861F938E216A1 /* APIClientTagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */; };
E1642E8329DC145DA96584D4 /* LinkedInTargetModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C9FAA36F8413788E1776C7 /* LinkedInTargetModelTests.swift */; };
E1F7D081DC89245C81E2047C /* AppDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */; };
E21121B51C15922CFBF92C43 /* DocumentSyncOutboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */; };
E51C9FF84350C46C947AC953 /* ServerLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0221B233F89B966D044C37FA /* ServerLimits.swift */; };
E64747D9845D6726CC1A1485 /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */; };
EA48487E20A6BECF67D3C060 /* ILWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */; };
EC636FE56A7CC64D7D01F64B /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5569B56DBD6747BB9587870 /* SessionsView.swift */; };
Expand Down Expand Up @@ -190,6 +195,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
0221B233F89B966D044C37FA /* ServerLimits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimits.swift; sourceTree = "<group>"; };
03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLinksSheet.swift; sourceTree = "<group>"; };
03D6C33FD59101503195F14F /* SharedDocumentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedDocumentView.swift; sourceTree = "<group>"; };
05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedTruncationTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -230,6 +236,7 @@
5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessor.swift; sourceTree = "<group>"; };
5CF8FDEBF56E4CAB421FD811 /* DocumentSyncOutbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutbox.swift; sourceTree = "<group>"; };
644B1B09F84332A794680ED7 /* APIClientMessageByIdTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientMessageByIdTests.swift; sourceTree = "<group>"; };
68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimitsStore.swift; sourceTree = "<group>"; };
6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientModerationTests.swift; sourceTree = "<group>"; };
74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicBrowse.swift; sourceTree = "<group>"; };
7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = "<group>"; };
Expand All @@ -247,8 +254,10 @@
93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = "<group>"; };
93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSearchUsersTests.swift; sourceTree = "<group>"; };
956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDeepLinkParseTests.swift; sourceTree = "<group>"; };
96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLimitsTests.swift; sourceTree = "<group>"; };
98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = "<group>"; };
9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharedListsTests.swift; sourceTree = "<group>"; };
A026223AD393B5E1A339E045 /* APIClient+Limits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Limits.swift"; sourceTree = "<group>"; };
A1B1C1D1E1F10000 /* InterlinedList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InterlinedList.app; sourceTree = BUILT_PRODUCTS_DIR; };
A1B1C1D1E1F10002 /* InterlinedListApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListApp.swift; sourceTree = "<group>"; };
A1B1C1D1E1F10004 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -310,6 +319,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>"; };
D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServerLimitsStoreTests.swift; sourceTree = "<group>"; };
D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutboxTests.swift; sourceTree = "<group>"; };
D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.swift; sourceTree = "<group>"; };
D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+SharedLists.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -453,6 +463,7 @@
236670B73F2B603E5EB318D7 /* ShareInvite.swift */,
B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */,
C470172F5310397C2E6195BC /* SharedResolution.swift */,
0221B233F89B966D044C37FA /* ServerLimits.swift */,
);
path = Models;
sourceTree = "<group>";
Expand All @@ -478,6 +489,8 @@
BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */,
256FE9818A171E11DDE43140 /* APIClientTransport.swift */,
D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */,
A026223AD393B5E1A339E045 /* APIClient+Limits.swift */,
68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -599,6 +612,7 @@
907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */,
4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */,
9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */,
96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */,
);
path = APIClientTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -641,6 +655,7 @@
8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */,
D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */,
126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */,
D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */,
);
path = ServiceTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -839,6 +854,9 @@
7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */,
4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */,
DDFD75240429AC8C22B4A828 /* APIClient+SharedLists.swift in Sources */,
E51C9FF84350C46C947AC953 /* ServerLimits.swift in Sources */,
C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */,
745483850C410FDE3C23D44F /* ServerLimitsStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -918,6 +936,8 @@
153437B4818A068C9ADE32DE /* APIClientLinkMetadataTests.swift in Sources */,
56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */,
4AD79062F63869F2EA8B24E9 /* APIClientSharedListsTests.swift in Sources */,
DB762CA98AEE01D098940CED /* APIClientLimitsTests.swift in Sources */,
94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
66 changes: 66 additions & 0 deletions InterlinedList/Models/ServerLimits.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// ServerLimits.swift
// InterlinedList
//

import CoreGraphics
import Foundation

/// The upload and content caps the backend enforces, from `GET /api/limits`
/// (public, no auth). Reading them rather than hardcoding matters most for
/// images: the server resizes every upload to `image.maxPixels` per side
/// regardless of what the client sends, so anything larger is wasted bytes on
/// the wire and a slower post.
struct ServerLimits: Codable, Equatable, Sendable {
let media: Media
let message: MessageLimits?

struct Media: Codable, Equatable, Sendable {
let image: ImageLimits
let video: VideoLimits?
}

struct ImageLimits: Codable, Equatable, Sendable {
let maxBytes: Int
let maxPixels: Int
let acceptedFormats: [String]?
}

struct VideoLimits: Codable, Equatable, Sendable {
let maxBytes: Int
let acceptedFormats: [String]?
}

struct MessageLimits: Codable, Equatable, Sendable {
let maxContentLength: Int?
}

/// Used until `GET /api/limits` answers, and if it never does. These are the
/// values the deployed backend reports today, so a failed fetch costs
/// accuracy only if the server's caps change.
static let fallback = ServerLimits(
media: Media(
image: ImageLimits(maxBytes: 1_400_000, maxPixels: 1200, acceptedFormats: nil),
video: VideoLimits(maxBytes: 3_145_728, acceptedFormats: nil)
),
message: MessageLimits(maxContentLength: 5000)
)
}

/// The subset `ImageUploadProcessor` needs, in the units it works in.
struct ImageUploadLimits: Equatable, Sendable {
let maxUploadBytes: Int
let maxPixels: CGFloat

init(maxUploadBytes: Int, maxPixels: CGFloat) {
self.maxUploadBytes = maxUploadBytes
self.maxPixels = maxPixels
}

init(_ limits: ServerLimits) {
self.init(maxUploadBytes: limits.media.image.maxBytes,
maxPixels: CGFloat(limits.media.image.maxPixels))
}

static let fallback = ImageUploadLimits(ServerLimits.fallback)
}
15 changes: 15 additions & 0 deletions InterlinedList/Services/APIClient+Limits.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// APIClient+Limits.swift
// InterlinedList
//

import Foundation

extension APIClient {

/// `GET /api/limits` — the upload and content caps the backend enforces.
/// Public: it takes no auth and is safe to call before sign-in.
func serverLimits() async throws -> ServerLimits {
try await get("/api/limits")
}
}
6 changes: 6 additions & 0 deletions InterlinedList/Services/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,12 @@ final class APIClient {
let _: OkResponse = try await patch("/api/notifications/\(encoded)/read", body: Empty())
}

/// `DELETE /api/notifications/:id` — removes one notification for good
/// (answers 204, and 404 if it isn't the caller's).
func deleteNotification(id: String) async throws {
try await delete("/api/notifications/\(pathSegment(id))")
}

func markAllNotificationsRead() async throws {
struct Empty: Encodable {}
struct OkResponse: Decodable { let ok: Bool; let updated: Int? }
Expand Down
Loading