Skip to content
Open
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
12 changes: 12 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */; };
4AD79062F63869F2EA8B24E9 /* APIClientSharedListsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */; };
4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256FE9818A171E11DDE43140 /* APIClientTransport.swift */; };
4CE14BC5E23958D0C0C275D6 /* APIClientSingleListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B0E633AE3DAB1E526CCB0EB /* APIClientSingleListTests.swift */; };
54CB167C520E0D951820214F /* AIWritingAssistantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */; };
55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; };
56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; };
Expand Down Expand Up @@ -110,6 +111,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 */; };
BCA5EFBE30EB9AB46C884724 /* APIClient+Lists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8C417F18CC7C89053F3D7E /* APIClient+Lists.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 */; };
Expand All @@ -135,6 +137,7 @@
D0A1D0A1D0A1000B /* LinkedIdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000C /* LinkedIdentitiesView.swift */; };
D0A1D0A1D0A1000D /* OAuthSignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1D0A1D0A1000E /* OAuthSignInButton.swift */; };
D0F35ACF98DDE854A8437C7D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD102A0D8ECD7CFC11DBD4A /* SettingsView.swift */; };
D3560D56DEA6AA150B284A14 /* ListLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F00D95CBB550904B2C5298 /* ListLinkView.swift */; };
D51B991E570C29EC9D0FD3A4 /* AIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2A39A4CB7CD21E155C1AD77 /* AIService.swift */; };
D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E7411CA219F3E8EB351BAB /* SharedListView.swift */; };
D6AA238FF5D318D9C03FA6B5 /* PublicListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */; };
Expand Down Expand Up @@ -223,10 +226,12 @@
08D80C6D8E2B0C7A3AB7E0B2 /* DocumentLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentLinkView.swift; sourceTree = "<group>"; };
09470364B5A0A559FDE3215F /* APIClientPushTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientPushTests.swift; sourceTree = "<group>"; };
0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIServiceError.swift; sourceTree = "<group>"; };
0B0E633AE3DAB1E526CCB0EB /* APIClientSingleListTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSingleListTests.swift; sourceTree = "<group>"; };
0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLinkedInTargetsTests.swift; sourceTree = "<group>"; };
0EF29F5DC946D80A63000209 /* FollowListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FollowListView.swift; sourceTree = "<group>"; };
0FA0B9A4EE76356EA1EF5785 /* UserSession.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = "<group>"; };
109F961E434AB806655FCFCF /* APIClientGitHubTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIClientGitHubTests.swift; path = APIClientGitHubTests.swift; sourceTree = "<group>"; };
10F00D95CBB550904B2C5298 /* ListLinkView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListLinkView.swift; sourceTree = "<group>"; };
1147B978D9E72695E4457454 /* Moderation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Moderation.swift; sourceTree = "<group>"; };
126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflictTests.swift; sourceTree = "<group>"; };
12F53DE7756042E68A3D1C33 /* APIClientTagsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTagsTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -270,6 +275,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; };
7B8C417F18CC7C89053F3D7E /* APIClient+Lists.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Lists.swift"; sourceTree = "<group>"; };
7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentTemplatesTests.swift; sourceTree = "<group>"; };
7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploader.swift; sourceTree = "<group>"; };
7F919661375CA53C1864025A /* AIArtifact.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifact.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -540,6 +546,7 @@
68CFEE33FB38834B254E8450 /* ServerLimitsStore.swift */,
7318B0E3D687653B06626F64 /* APIClient+AI.swift */,
C2A39A4CB7CD21E155C1AD77 /* AIService.swift */,
7B8C417F18CC7C89053F3D7E /* APIClient+Lists.swift */,
);
path = Services;
sourceTree = "<group>";
Expand Down Expand Up @@ -607,6 +614,7 @@
77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */,
5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */,
B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */,
10F00D95CBB550904B2C5298 /* ListLinkView.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -669,6 +677,7 @@
9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */,
96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */,
EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */,
0B0E633AE3DAB1E526CCB0EB /* APIClientSingleListTests.swift */,
);
path = APIClientTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -932,6 +941,8 @@
FDF51BC81627FCB138474AFE /* AISeriesGeneratorSection.swift in Sources */,
6DACF0399E254AAA27286324 /* AIPoweredTemplateView.swift in Sources */,
E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */,
BCA5EFBE30EB9AB46C884724 /* APIClient+Lists.swift in Sources */,
D3560D56DEA6AA150B284A14 /* ListLinkView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -1018,6 +1029,7 @@
016599A41F91048F2AA1AF10 /* AIArtifactTests.swift in Sources */,
8C393D2AE39FAEFD773AAF53 /* AIComposerLogicTests.swift in Sources */,
C4C3B533A4C5F76394318FAB /* AIServiceTests.swift in Sources */,
4CE14BC5E23958D0C0C275D6 /* APIClientSingleListTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
66 changes: 57 additions & 9 deletions InterlinedList/InterlinedListApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ struct InterlinedListApp: App {
case .document(let id):
DocumentLinkView(documentId: id)
.environmentObject(authState)
case .list(let id):
ListLinkView(listId: id)
.environmentObject(authState)
.environmentObject(store)
case .publicList(let owner, let id):
NavigationStack {
PublicListDetailView(username: owner, listId: id)
.toolbar { deepLinkDoneButton }
}
.environmentObject(authState)
case .publicDocument(_, let id):
NavigationStack {
PublicDocumentReader(documentId: id, title: "Document")
.toolbar { deepLinkDoneButton }
}
.environmentObject(authState)
case .sharedDocument(let token):
SharedDocumentView(token: token)
.environmentObject(authState)
Expand All @@ -82,6 +98,15 @@ struct InterlinedListApp: App {
}
}

/// The public list/document readers are built to be *pushed*, so presenting one
/// from a deep link has to supply its own way back out.
@ToolbarContentBuilder
private var deepLinkDoneButton: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { router.pendingDeepLink = nil }
}
}

// A2: Universal Links (a tapped https://interlinedlist.com link opening the app
// directly). The Associated Domains entitlement (applinks:interlinedlist.com) is now
// in place and `parse` already accepts https permalinks, so onOpenURL will route them
Expand All @@ -101,7 +126,8 @@ struct InterlinedListApp: App {
Task { await verifyEmail(token: token) }
case .verifyEmailChange(let token):
Task { await verifyEmailChange(token: token) }
case .resetPassword, .userProfile, .message, .document, .sharedDocument, .sharedList:
case .resetPassword, .userProfile, .message, .document, .list,
.publicList, .publicDocument, .sharedDocument, .sharedList:
router.pendingDeepLink = link
}
}
Expand Down Expand Up @@ -154,6 +180,11 @@ enum AppDeepLink: Identifiable, Hashable {
case userProfile(username: String)
case message(id: String)
case document(id: String)
case list(id: String)
/// Owner-scoped public permalinks (`/user/<owner>/lists|documents/<id>`) —
/// the links the web shows when a list or document is made public.
case publicList(owner: String, id: String)
case publicDocument(owner: String, id: String)
case sharedDocument(token: String)
case sharedList(token: String)

Expand All @@ -165,16 +196,20 @@ enum AppDeepLink: Identifiable, Hashable {
case .userProfile(let username): return "profile:" + username
case .message(let id): return "message:" + id
case .document(let id): return "document:" + id
case .list(let id): return "list:" + id
case .publicList(let owner, let id): return "public-list:" + owner + "/" + id
case .publicDocument(let owner, let id): return "public-document:" + owner + "/" + id
case .sharedDocument(let token): return "shared-document:" + token
case .sharedList(let token): return "shared-list:" + token
}
}

/// Parses both the custom scheme (`interlinedlist://…`, where the target is the
/// URL host or first path segment) and canonical web permalinks
/// (`https://interlinedlist.com/…` / `https://www.interlinedlist.com/…`). Content
/// links map to `.userProfile` / `.message`; auth links preserve their `?token`.
/// Returns nil for unknown targets or web hosts other than interlinedlist.com.
/// (`https://interlinedlist.com/…` / `https://www.interlinedlist.com/…`), including
/// the owner-scoped shapes the web hands out (`/user/<u>/status/<id>` is the
/// canonical *message* permalink, not a profile link); auth links preserve their
/// `?token`. Returns nil for unknown targets or hosts other than interlinedlist.com.
static func parse(_ url: URL) -> AppDeepLink? {
let scheme = url.scheme?.lowercased()
let host = url.host?.lowercased() ?? ""
Expand Down Expand Up @@ -207,6 +242,16 @@ enum AppDeepLink: Identifiable, Hashable {
switch target {
case "user":
guard let username = segments.first, !username.isEmpty else { return nil }
// The web scopes its canonical permalinks to the author's profile, so the
// second segment — not the first — decides what a /user/… link opens.
if segments.count >= 3, !segments[2].isEmpty {
switch segments[1] {
case "status": return .message(id: segments[2])
case "lists": return .publicList(owner: username, id: segments[2])
case "documents": return .publicDocument(owner: username, id: segments[2])
default: break
}
}
return .userProfile(username: username)
case "message":
guard let id = segments.first, !id.isEmpty else { return nil }
Expand All @@ -222,11 +267,14 @@ enum AppDeepLink: Identifiable, Hashable {
return .document(id: id)
case "lists":
// `/lists/shared/<token>` resolves a self-contained share-link token
// (read-only viewer). A bare `/lists/<id>` permalink is NOT routed — it
// carries no owner username, which the list-detail endpoint requires
// (see the-gaps.md); only the shared-token form is handled here.
guard segments.first == "shared", segments.count >= 2, !segments[1].isEmpty else { return nil }
return .sharedList(token: segments[1])
// (read-only viewer); a bare `/lists/<id>` is the authed permalink the
// app itself hands out (`ILWebURL.list(_:)`), routed to the list detail.
if segments.first == "shared" {
guard segments.count >= 2, !segments[1].isEmpty else { return nil }
return .sharedList(token: segments[1])
}
guard let id = segments.first, !id.isEmpty else { return nil }
return .list(id: id)
case "reset-password":
guard let token, !token.isEmpty else { return nil }
return .resetPassword(token: token)
Expand Down
33 changes: 33 additions & 0 deletions InterlinedList/Services/APIClient+Lists.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// APIClient+Lists.swift
// InterlinedList
//

import Foundation

/// Why a list a permalink points at cannot be shown.
enum ListAccessError: Error {
/// The viewer holds no role on this list (403), or it does not exist / is
/// deleted (404). The backend deliberately answers both for a list the
/// viewer may not see, so they collapse into one user-facing state.
case noAccess
}

extension APIClient {
/// A single list by id (`GET /api/lists/:id`).
///
/// Bearer-ready, and authorized by *role* — owner / manager / collaborator /
/// watcher — so a bare `/lists/<id>` permalink needs no owner username in the
/// URL to resolve. Throws `ListAccessError.noAccess` for 403/404.
func list(id: String) async throws -> UserList {
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
struct Response: Decodable {
let data: UserList
}
let response: Response = try await get(
"/api/lists/\(encoded)",
mappingStatuses: [403: ListAccessError.noAccess, 404: ListAccessError.noAccess]
)
return response.data
}
}
19 changes: 19 additions & 0 deletions InterlinedList/Services/APIClientTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ extension APIClient {
return data
}

/// GET that maps selected non-2xx statuses to caller-supplied errors *before*
/// the generic body-driven mapping runs. A route answering 403/404 with an
/// `{"error": …}` body otherwise arrives as `.forbidden`/`.server`, which
/// loses the distinction a permalink opener needs: "you have no access here"
/// reads very differently from "the server had a problem".
func get<T: Decodable>(_ path: String, mappingStatuses statusErrors: [Int: Error]) async throws -> T {
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
authorize(&request)
let (data, response) = try await session.data(for: request)
if let status = (response as? HTTPURLResponse)?.statusCode, let mapped = statusErrors[status] {
throw mapped
}
try checkResponse(data: data, response: response)
return try decoder.decode(T.self, from: data)
}

// MARK: - Writes (snake_case bodies)

func post<T: Decodable, B: Encodable>(_ path: String, body: B, authenticated: Bool = true) async throws -> T {
Expand Down
Loading
Loading