From 43f9d6052f7291009c765effe062fd4345d8531c Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 17:41:28 +0100 Subject: [PATCH 1/6] fix(shares): match documented API shape, fill in list/revoke, harden share sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share sheet errored "Got an unexpected response from the server" (a client-side decode failure) on every open. OutlineShare only declared 5 fields against the real response's ~20, with url non-optional — something in a real payload came back null and blew up the strict decode, same class of bug as OutlinePin's history: self-hosted responses keep diverging from what the hosted-app docs imply is non-nullable. - Rewrote OutlineShare to match the full documented shares.* response shape. Only id/published are trusted non-optional; everything else (documentTitle, sourceTitle, urlId, domain, title, iconUrl, includeChildDocuments, allowSubscriptions, allowIndexing, showLastUpdated, showTOC, views, createdBy, createdAt, updatedAt, lastAccessedAt) is optional so an unexpectedly-null field can't crash the decode again. - shares.list and shares.revoke were never wrapped at all — added both. - UpdateShareRequest was missing the documented title/iconUrl overrides. - DocumentShareSheet: handles share.url being optional, adds a public-page title override field, adds a Revoke Link button (confirmation dialog, resets back to "Create Share Link" after). - Removed dead DocumentReaderViewModel.share/loadShare()/ createOrLoadShare() — never called by anything; DocumentShareSheet manages its own share state independently. - Added a decode test using the exact payload from Outline's official shares.info docs, plus tests for shares.list and shares.revoke. Branched fresh off main (post home-page merge) rather than continuing on feature/home-page, to keep this its own PR. --- .../OutlineKit/Core/OutlineAPIClient.swift | 2 + .../OutlineKit/LiveOutlineAPIClient.swift | 8 ++ .../OutlineKit/Models/OutlineShare.swift | 35 +++++- .../Requests/ListSharesRequest.swift | 18 +++ .../Requests/UpdateShareRequest.swift | 9 +- .../LiveOutlineAPIClientTests.swift | 105 ++++++++++++++++++ .../Collections/DocumentReaderViewModel.swift | 9 -- .../Collections/DocumentShareSheet.swift | 96 +++++++++++++--- 8 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListSharesRequest.swift diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 90d34fb..cfdd47d 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -35,6 +35,8 @@ public protocol OutlineAPIClient: Sendable { func createShare(_ request: CreateShareRequest) async throws -> OutlineShare func shareInfo(documentId: String) async throws -> OutlineShare? func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare + func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] + func revokeShare(id: String) async throws /// See `OutlinePin` — best-effort, not in the vendored spec. func createPin(_ request: CreatePinRequest) async throws -> OutlinePin func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 18090e6..77154c4 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -132,6 +132,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("shares.update", body: request) } + public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { + try await post("shares.list", body: request) + } + + public func revokeShare(id: String) async throws { + try await postForSuccess("shares.revoke", body: StarIDParams(id: id)) + } + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { try await post("pins.create", body: request) } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift index 1cb4660..c507af3 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineShare.swift @@ -1,10 +1,41 @@ import Foundation /// A public share link for a document or collection, backed by `shares.*`. +/// +/// Only `id` and `published` are guaranteed present on every real share +/// object — everything else is modeled as optional even where the official +/// API docs don't explicitly mark it nullable, since self-hosted servers +/// have repeatedly diverged from the hosted app's documented response shape +/// (see `OutlinePin`'s history) and a single unexpectedly-null field used to +/// crash this decode entirely ("Got an unexpected response from the +/// server"). public struct OutlineShare: Decodable, Identifiable, Sendable { public let id: String + public let published: Bool + public let documentId: String? public let collectionId: String? - public let url: String - public let published: Bool + public let documentTitle: String? + public let documentUrl: String? + public let sourceTitle: String? + public let sourcePath: String? + public let urlId: String? + public let url: String? + public let domain: String? + /// Overrides the source document/collection title on the publicly + /// shared page, if set. + public let title: String? + /// Overrides the workspace branding icon on the publicly shared page, + /// if set. + public let iconUrl: String? + public let includeChildDocuments: Bool? + public let allowSubscriptions: Bool? + public let allowIndexing: Bool? + public let showLastUpdated: Bool? + public let showTOC: Bool? + public let views: Int? + public let createdBy: OutlineUser? + public let createdAt: Date? + public let updatedAt: Date? + public let lastAccessedAt: Date? } diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListSharesRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListSharesRequest.swift new file mode 100644 index 0000000..ea8869f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListSharesRequest.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct ListSharesRequest: Encodable, Sendable { + public let offset: Int + public let limit: Int + public let sort: String? + public let direction: String? + /// Filter to shared documents matching a search query. + public let query: String? + + public init(offset: Int = 0, limit: Int = 25, sort: String? = nil, direction: String? = nil, query: String? = nil) { + self.offset = offset + self.limit = limit + self.sort = sort + self.direction = direction + self.query = query + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift index 9b9abc5..322a298 100644 --- a/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateShareRequest.swift @@ -3,9 +3,16 @@ import Foundation public struct UpdateShareRequest: Encodable, Sendable { public let id: String public let published: Bool + /// Overrides the title displayed on the publicly shared page. `nil` + /// leaves it unset in the payload (server keeps whatever it already + /// has) rather than clearing it — send an empty string to clear. + public let title: String? + public let iconUrl: String? - public init(id: String, published: Bool) { + public init(id: String, published: Bool, title: String? = nil, iconUrl: String? = nil) { self.id = id self.published = published + self.title = title + self.iconUrl = iconUrl } } diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 7ac7075..712683d 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -669,6 +669,111 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") } + func testShareInfoDecodesFullDocumentedShape() async throws { + // Matches the exact response shape from Outline's official + // shares.info docs, including every field the hosted app can send — + // the model must tolerate all of this without throwing. + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "documentTitle": "React best practices", + "documentUrl": "https://example.com", + "sourceTitle": "string", + "sourcePath": "string", + "documentId": null, + "collectionId": null, + "urlId": null, + "url": "https://example.com", + "domain": null, + "title": null, + "iconUrl": null, + "published": false, + "includeChildDocuments": true, + "allowSubscriptions": true, + "allowIndexing": true, + "showLastUpdated": true, + "showTOC": true, + "views": 1, + "createdAt": "2026-08-12T17:41:28.333Z", + "createdBy": { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "Jane Doe", + "avatarUrl": "https://example.com", + "color": "string", + "email": "hello@example.com", + "role": "admin", + "isSuspended": true, + "lastActiveAt": null, + "timezone": null, + "createdAt": "2026-08-12T17:41:28.333Z", + "updatedAt": "2026-08-12T17:41:28.333Z", + "deletedAt": null + }, + "updatedAt": "2026-08-12T17:41:28.333Z", + "lastAccessedAt": null + }, + "policies": [ + { "id": "123e4567-e89b-12d3-a456-426614174000", "abilities": { "read": true, "update": true, "delete": false } } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let share = try await client.shareInfo(documentId: "doc-1") + + XCTAssertEqual(share?.published, false) + XCTAssertEqual(share?.views, 1) + XCTAssertEqual(share?.createdBy?.name, "Jane Doe") + XCTAssertNil(share?.documentId) + } + + func testListSharesDecodesSharesArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { "id": "share-1", "documentId": "doc-1", "collectionId": null, "url": "https://outline.example.com/s/share-1", "published": true } + ], + "pagination": { "offset": 0, "limit": 25 } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let shares = try await client.listShares(ListSharesRequest()) + + XCTAssertEqual(shares.first?.id, "share-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.list") + } + + func testRevokeShareSendsRequest() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.revokeShare(id: "share-1") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.revoke") + } + func testShareInfoReturnsNilOnNotFound() async throws { let httpClient = MockHTTPClient() httpClient.statusCode = 404 diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 3005440..92b329a 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -28,7 +28,6 @@ final class DocumentReaderViewModel { private var pinId: String? private(set) var isSubscribed = false private var subscriptionId: String? - private(set) var share: OutlineShare? /// `nil` until checked. Inferred from whether `documents.insights` /// succeeds or fails — `insightsEnabled` isn't readable back off @@ -102,10 +101,6 @@ final class DocumentReaderViewModel { } } - func loadShare() async { - share = try? await apiClient.shareInfo(documentId: documentId) - } - func loadInsightsEnabledState() async { do { _ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId)) @@ -151,10 +146,6 @@ final class DocumentReaderViewModel { } } - func createOrLoadShare() async throws { - share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) - } - /// Turning editing off saves; turning it on is just a mode switch. func toggleEditing() async { guard isEditing else { diff --git a/Outpost/Features/Collections/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift index 1cae350..f4dc33f 100644 --- a/Outpost/Features/Collections/DocumentShareSheet.swift +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -11,8 +11,11 @@ struct DocumentShareSheet: View { let documentId: String @State private var share: OutlineShare? + @State private var titleOverride = "" @State private var isLoading = false @State private var isUpdating = false + @State private var isRevoking = false + @State private var isShowingRevokeConfirmation = false @State private var errorMessage: String? @State private var didCopy = false @@ -32,21 +35,23 @@ struct DocumentShareSheet: View { .font(.callout) .foregroundStyle(.red) } else if let share { - HStack { - Text(share.url) - .font(.callout) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - copyLink(share.url) - } label: { - Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + if let url = share.url { + HStack { + Text(url) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + copyLink(url) + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) } - .padding(8) - .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) Toggle( "Published — accessible without sign-in", @@ -56,6 +61,25 @@ struct DocumentShareSheet: View { ) ) .disabled(isUpdating) + + VStack(alignment: .leading, spacing: 4) { + Text("Public page title") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Uses the document's title if empty", text: $titleOverride) + .textFieldStyle(.roundedBorder) + .disabled(isUpdating) + .onSubmit { + Task { await setTitle(titleOverride) } + } + } + + Divider() + + Button("Revoke Link", role: .destructive) { + isShowingRevokeConfirmation = true + } + .disabled(isRevoking) } else { Button("Create Share Link") { Task { await create() } @@ -65,6 +89,18 @@ struct DocumentShareSheet: View { .padding(20) .frame(width: 380) .task { await load() } + .confirmationDialog( + "Revoke this share link?", + isPresented: $isShowingRevokeConfirmation, + titleVisibility: .visible + ) { + Button("Revoke", role: .destructive) { + Task { await revoke() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Anyone using this link will no longer be able to access the document.") + } } private func copyLink(_ url: String) { @@ -83,6 +119,7 @@ struct DocumentShareSheet: View { defer { isLoading = false } do { share = try await apiClient.shareInfo(documentId: documentId) + titleOverride = share?.title ?? "" } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") } @@ -93,6 +130,7 @@ struct DocumentShareSheet: View { defer { isLoading = false } do { share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) + titleOverride = share?.title ?? "" } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") } @@ -103,10 +141,40 @@ struct DocumentShareSheet: View { isUpdating = true defer { isUpdating = false } do { - self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published)) + self.share = try await apiClient.updateShare( + UpdateShareRequest(id: share.id, published: published, title: share.title) + ) } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") } } + + private func setTitle(_ title: String) async { + guard let share else { return } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed != (share.title ?? "") else { return } + isUpdating = true + defer { isUpdating = false } + do { + self.share = try await apiClient.updateShare( + UpdateShareRequest(id: share.id, published: share.published, title: trimmed) + ) + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + } + } + + private func revoke() async { + guard let share else { return } + isRevoking = true + defer { isRevoking = false } + do { + try await apiClient.revokeShare(id: share.id) + self.share = nil + titleOverride = "" + } catch { + errorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.") + } + } } #endif From 1476c6c6ba08c62884a4140c3480a668494dcf8f Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 17:49:37 +0100 Subject: [PATCH 2/6] fix(shares): treat shares.info's empty-body response as no-share, not an error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-shape fix wasn't the actual bug — confirmed live that shares.info returns HTTP 200 with a completely empty body (not a 404, not {data: null}) when no share exists yet for a document. post(_:) assumed any 2xx had a non-empty envelope to decode, so this hit JSONDecoder with zero bytes and threw "not valid JSON" on every first share-sheet open. Added postOptional(_:body:), mirroring post/postForSuccess, that treats an empty body the same as a 404: nil, not a decode failure. shareInfo calls it directly instead of wrapping post() with a notFound-only catch. --- .../OutlineKit/LiveOutlineAPIClient.swift | 52 +++++++++++++++++-- .../LiveOutlineAPIClientTests.swift | 18 +++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 77154c4..39d29be 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -121,11 +121,7 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } public func shareInfo(documentId: String) async throws -> OutlineShare? { - do { - return try await post("shares.info", body: ShareInfoRequest(documentId: documentId)) - } catch OutlineAPIError.notFound { - return nil - } + try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId)) } public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { @@ -251,6 +247,52 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } } + /// Like `post(_:body:)`, but for endpoints where "no result" comes back + /// as a 200 with a completely empty body instead of a real 404 — + /// confirmed against a live server for `shares.info` (no share yet for + /// a given document). A 404 is still treated as nil too. + private func postOptional(_ path: String, body: Body) async throws -> Response? { + guard let token = try? tokenStore.token() else { + throw OutlineAPIError.tokenUnavailable + } + + var request = URLRequest(url: baseURL.appendingPathComponent("api/\(path)")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try encoder.encode(body) + + let data: Data + let response: HTTPURLResponse + do { + (data, response) = try await httpClient.send(request) + } catch let error as OutlineAPIError { + throw error + } catch { + throw OutlineAPIError.transport(error) + } + + guard (200...299).contains(response.statusCode) else { + let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data) + switch response.statusCode { + case 401: + throw OutlineAPIError.unauthorized + case 404: + return nil + default: + throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error) + } + } + + guard !data.isEmpty else { return nil } + + do { + return try decoder.decode(OutlineEnvelope.self, from: data).data + } catch { + throw OutlineAPIError.decoding(error) + } + } + /// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }` /// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit. private func postForSuccess(_ path: String, body: Body) async throws { diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 712683d..eae2b40 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -792,6 +792,24 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertNil(share) } + func testShareInfoReturnsNilOnEmptyBody() async throws { + // Confirmed against a live server: shares.info returns a 200 with a + // completely empty body (not a 404) when no share exists yet for a + // given document. + let httpClient = MockHTTPClient() + httpClient.responseData = Data() + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let share = try await client.shareInfo(documentId: "doc-1") + + XCTAssertNil(share) + } + func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws { let httpClient = MockHTTPClient() httpClient.responseData = """ From 013df89b4fdbed50bca7f248c213d954b9b84508 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 17:55:07 +0100 Subject: [PATCH 3/6] fix(shares): shares.info wraps the share in {shares: [...]}, not bare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty-body handling fixed "no share yet" but re-opening the share sheet for a document that already had one still errored — confirmed via a raw response capture that data is { shares: [...] }, a one-element array, not the bare share object the docs show. Same pattern as pins.list. Added SharesInfoPayload (mirrors PinsListPayload), shareInfo now takes .shares.first. createShare/updateShare are unaffected — the user's earlier successful create-and-copy confirms those aren't wrapped this way, only shares.info. Test rewritten with the exact captured payload. --- .../OutlineKit/LiveOutlineAPIClient.swift | 12 ++- .../LiveOutlineAPIClientTests.swift | 96 ++++++++++--------- 2 files changed, 62 insertions(+), 46 deletions(-) diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 39d29be..a7f3029 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -121,7 +121,13 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { } public func shareInfo(documentId: String) async throws -> OutlineShare? { - try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId)) + // Real shape confirmed against a live server: `data` is + // `{ shares: [...] }`, not the bare share object the docs imply — + // same pattern as `pins.list`. A document could in principle have + // more than one share record; the first is what the reader's + // share sheet cares about. + let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId)) + return payload?.shares.first } public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { @@ -354,6 +360,10 @@ private struct PinsListPayload: Decodable { let documents: [OutlineDocument] } +private struct SharesInfoPayload: Decodable { + let shares: [OutlineShare] +} + private struct ListStarsResponse: Decodable { let stars: [OutlineStar] } diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index eae2b40..998300d 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -669,54 +669,59 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") } - func testShareInfoDecodesFullDocumentedShape() async throws { - // Matches the exact response shape from Outline's official - // shares.info docs, including every field the hosted app can send — - // the model must tolerate all of this without throwing. + func testShareInfoDecodesRealShareArrayShape() async throws { + // Real shape confirmed against a live server — `data` is + // `{ shares: [...] }`, not the bare share object the official docs + // imply (same pattern as `pins.list`). This is the exact payload + // captured for an existing, unpublished share. let httpClient = MockHTTPClient() httpClient.responseData = """ { "data": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "documentTitle": "React best practices", - "documentUrl": "https://example.com", - "sourceTitle": "string", - "sourcePath": "string", - "documentId": null, - "collectionId": null, - "urlId": null, - "url": "https://example.com", - "domain": null, - "title": null, - "iconUrl": null, - "published": false, - "includeChildDocuments": true, - "allowSubscriptions": true, - "allowIndexing": true, - "showLastUpdated": true, - "showTOC": true, - "views": 1, - "createdAt": "2026-08-12T17:41:28.333Z", - "createdBy": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "name": "Jane Doe", - "avatarUrl": "https://example.com", - "color": "string", - "email": "hello@example.com", - "role": "admin", - "isSuspended": true, - "lastActiveAt": null, - "timezone": null, - "createdAt": "2026-08-12T17:41:28.333Z", - "updatedAt": "2026-08-12T17:41:28.333Z", - "deletedAt": null - }, - "updatedAt": "2026-08-12T17:41:28.333Z", - "lastAccessedAt": null + "shares": [ + { + "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", + "sourceTitle": "Test Document 3", + "sourcePath": "/doc/test-document-3-VHxABl5RaD", + "collectionId": null, + "documentId": "b1196971-4239-4e35-970f-7fbbc60af044", + "documentTitle": "Test Document 3", + "documentUrl": "/doc/test-document-3-VHxABl5RaD", + "published": false, + "url": "https://docs.psmattas.com/s/861559ac-906b-4dec-9c1f-3a2d2000745d", + "urlId": null, + "createdBy": { + "id": "1e2ef39c-aa82-475b-b5af-d76bb4f023ed", + "name": "Puranjay Savar Mattas", + "avatarUrl": "/api/files.get?key=public/avatar.png", + "color": "#1c9152", + "role": "admin", + "isSuspended": false, + "createdAt": "2025-07-30T17:36:58.560Z", + "updatedAt": "2026-08-14T16:47:45.037Z", + "deletedAt": null, + "lastActiveAt": "2026-08-14T16:47:45.037Z", + "timezone": "Europe/Dublin" + }, + "includeChildDocuments": false, + "allowIndexing": false, + "allowSubscriptions": true, + "showLastUpdated": false, + "showTOC": false, + "title": null, + "iconUrl": null, + "views": 0, + "domain": null, + "createdAt": "2026-08-14T16:49:40.146Z", + "updatedAt": "2026-08-14T16:49:40.146Z" + } + ] }, "policies": [ - { "id": "123e4567-e89b-12d3-a456-426614174000", "abilities": { "read": true, "update": true, "delete": false } } - ] + { "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } } + ], + "status": 200, + "ok": true } """.data(using: .utf8)! @@ -728,10 +733,11 @@ final class LiveOutlineAPIClientTests: XCTestCase { let share = try await client.shareInfo(documentId: "doc-1") + XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d") XCTAssertEqual(share?.published, false) - XCTAssertEqual(share?.views, 1) - XCTAssertEqual(share?.createdBy?.name, "Jane Doe") - XCTAssertNil(share?.documentId) + XCTAssertEqual(share?.views, 0) + XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas") + XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044") } func testListSharesDecodesSharesArray() async throws { From 7216633299224b9219fc71892558fa22682135fc Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 20:18:03 +0100 Subject: [PATCH 4/6] feat(share): drop broken Published toggle, add real document permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flipping "Published" 403'd with authorization_error, confirmed via a raw curl (bypassing our client entirely, real token) to be a genuine server-side restriction independent of this app — workspace public sharing is enabled, token is full-scope, it's not document-specific. Root cause is most likely Outline gating that action behind an interactive session rather than API-token auth, but that's not definitively confirmed server-side. Removed the toggle per explicit instruction; kept link create/copy/revoke and title override (same endpoint, not reported broken). Replaced it with real per-document user permissions: - OutlineMembership/OutlineDocumentMember models - documents.add_user (confirmed shape from official docs), documents.remove_user/documents.users (speculative, same "best-effort until a live server confirms" treatment OutlinePin originally got), users.list for the invite search (standard, high-confidence) - DocumentShareSheet gets a "People with access" section: search and invite with a Can-view/Can-edit picker, existing members listed with a remove button - Reader toolbar's long-disabled "Permissions…" menu item now opens this same sheet instead of doing nothing Sidebar's own disabled "Permissions…" stub has no sheet wired up to it yet — comment updated to be accurate, not fixed (use the reader's menu instead). documents.users/documents.remove_user are unverified against a live server, same as every other speculative endpoint this session — expect a correction round once tested. --- .../OutlineKit/Core/OutlineAPIClient.swift | 8 + .../OutlineKit/LiveOutlineAPIClient.swift | 16 + .../OutlineKit/Models/OutlineMembership.swift | 31 ++ .../Requests/AddDocumentUserRequest.swift | 16 + .../Requests/ListDocumentUsersRequest.swift | 17 + .../Requests/ListUsersRequest.swift | 15 + .../Requests/RemoveDocumentUserRequest.swift | 14 + .../LiveOutlineAPIClientTests.swift | 83 +++++ .../CollectionDocumentsOutline.swift | 5 +- .../Collections/DocumentReaderView.swift | 10 +- .../Collections/DocumentShareSheet.swift | 311 +++++++++++++----- 11 files changed, 437 insertions(+), 89 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift create mode 100644 OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index cfdd47d..c1b0eac 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -48,6 +48,14 @@ public protocol OutlineAPIClient: Sendable { /// Historical view records, not live presence. Backed by `views.list`. func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] + /// See `OutlineMembership`/`OutlineDocumentMember` — `add` is confirmed + /// from Outline's official docs, the rest are best-effort. + func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership + func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws + func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] + /// For searching workspace members to invite. Backed by `users.list`. + func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] + func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] func collectionInfo(id: String) async throws -> OutlineCollection func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index a7f3029..13ca7e1 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -175,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("views.list", body: request) } + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { + try await post("documents.add_user", body: request) + } + + public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { + try await postForSuccess("documents.remove_user", body: request) + } + + public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { + try await post("documents.users", body: request) + } + + public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { + try await post("users.list", body: request) + } + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit)) } diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift new file mode 100644 index 0000000..114efad --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineMembership.swift @@ -0,0 +1,31 @@ +import Foundation + +/// A user's explicit permission grant on a document, backed by +/// `documents.add_user`/`documents.remove_user`. Not in the vendored spec — +/// only `documents.add_user`'s shape is confirmed from Outline's official +/// docs; `remove` and the response shape here follow the create/delete and +/// `{data: ...}` conventions used throughout the rest of this API, but +/// aren't verified against a live server yet. +public struct OutlineMembership: Decodable, Identifiable, Sendable { + public let id: String + public let userId: String + public let documentId: String? + /// `"read"` or `"read_write"` per the documented enum. + public let permission: String +} + +/// A workspace member as returned by `documents.users` in the context of a +/// specific document — same identity fields as `OutlineUser`, plus (if the +/// server includes it) the permission they hold on that document. Modeled +/// separately from `OutlineUser` rather than adding an optional field there, +/// since `permission` only makes sense in this document-scoped context. +/// Speculative — `documents.users` isn't in the vendored spec, its name is +/// inferred from Outline's usual `.` convention and hasn't +/// been confirmed against a live server. +public struct OutlineDocumentMember: Decodable, Identifiable, Sendable { + public let id: String + public let name: String + public let email: String? + public let avatarUrl: String? + public let permission: String? +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift new file mode 100644 index 0000000..d5d141c --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/AddDocumentUserRequest.swift @@ -0,0 +1,16 @@ +import Foundation + +/// See `OutlineMembership`. Confirmed shape from Outline's official +/// `documents.add_user` docs. +public struct AddDocumentUserRequest: Encodable, Sendable { + public let id: String + public let userId: String + /// `"read"` or `"read_write"`. + public let permission: String + + public init(id: String, userId: String, permission: String) { + self.id = id + self.userId = userId + self.permission = permission + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift new file mode 100644 index 0000000..9cd7870 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListDocumentUsersRequest.swift @@ -0,0 +1,17 @@ +import Foundation + +/// See `OutlineDocumentMember`. Speculative — `documents.users` isn't in +/// the vendored spec. +public struct ListDocumentUsersRequest: Encodable, Sendable { + public let id: String + public let query: String? + public let offset: Int + public let limit: Int + + public init(id: String, query: String? = nil, offset: Int = 0, limit: Int = 25) { + self.id = id + self.query = query + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift new file mode 100644 index 0000000..7d3c17d --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListUsersRequest.swift @@ -0,0 +1,15 @@ +import Foundation + +/// For searching workspace members to invite to a document. Backed by +/// `users.list`. +public struct ListUsersRequest: Encodable, Sendable { + public let query: String? + public let offset: Int + public let limit: Int + + public init(query: String? = nil, offset: Int = 0, limit: Int = 25) { + self.query = query + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift new file mode 100644 index 0000000..7e609a1 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/RemoveDocumentUserRequest.swift @@ -0,0 +1,14 @@ +import Foundation + +/// See `OutlineMembership`. Speculative — follows the create/delete pairing +/// convention used elsewhere in this API (`documents.add_user` / +/// `documents.remove_user`), not confirmed against a live server yet. +public struct RemoveDocumentUserRequest: Encodable, Sendable { + public let id: String + public let userId: String + + public init(id: String, userId: String) { + self.id = id + self.userId = userId + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 998300d..39cb57d 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -910,6 +910,89 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create") } + func testAddDocumentUserDecodesMembership() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "data": { "id": "mem-1", "userId": "user-1", "documentId": "doc-1", "permission": "read" } } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let membership = try await client.addDocumentUser( + AddDocumentUserRequest(id: "doc-1", userId: "user-1", permission: "read") + ) + + XCTAssertEqual(membership.id, "mem-1") + XCTAssertEqual(membership.permission, "read") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.add_user") + } + + func testRemoveDocumentUserSendsRequest() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { "success": true } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + try await client.removeDocumentUser(RemoveDocumentUserRequest(id: "doc-1", userId: "user-1")) + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.remove_user") + } + + func testDocumentUsersDecodesMembersArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "avatarUrl": null, "permission": "read_write" } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let members = try await client.documentUsers(ListDocumentUsersRequest(id: "doc-1")) + + XCTAssertEqual(members.first?.name, "Jane Doe") + XCTAssertEqual(members.first?.permission, "read_write") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.users") + } + + func testListUsersDecodesUsersArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": [ + { "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "role": "member" } + ] + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let users = try await client.listUsers(ListUsersRequest(query: "Jane")) + + XCTAssertEqual(users.first?.name, "Jane Doe") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list") + } + func testMissingTokenThrowsTokenUnavailable() async throws { let httpClient = MockHTTPClient() let client = LiveOutlineAPIClient( diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 593fe2f..d627c0c 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -316,8 +316,9 @@ private struct DocumentNodeRow: View { renameText = node.document.title isShowingRenameAlert = true } - // Sharing/membership management is its own subsystem, not a one-off - // action — deferred rather than half-built here. + // DocumentShareSheet now has a real "People with access" section — + // this row just doesn't have a sheet wired up to present it yet + // (only the reader toolbar does). Use the reader's ⋯ menu instead. Button("Permissions…") {} .disabled(true) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 3ba40aa..e46b20c 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -299,10 +299,12 @@ struct DocumentReaderView: View { Button(viewModel.isEditing ? "Done Editing" : "Edit") { Task { await viewModel.toggleEditing() } } - // Sharing/membership management is its own subsystem, not a one-off - // action — deferred rather than half-built here. - Button("Permissions…") {} - .disabled(true) + // Membership management now lives in DocumentShareSheet's "People + // with access" section, alongside the share link — same sheet, + // same isShowingShareSheet state. + Button("Permissions…") { + isShowingShareSheet = true + } Divider() diff --git a/Outpost/Features/Collections/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift index f4dc33f..261184b 100644 --- a/Outpost/Features/Collections/DocumentShareSheet.swift +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -12,13 +12,23 @@ struct DocumentShareSheet: View { @State private var share: OutlineShare? @State private var titleOverride = "" - @State private var isLoading = false - @State private var isUpdating = false + @State private var isLoadingShare = false + @State private var isUpdatingShare = false @State private var isRevoking = false @State private var isShowingRevokeConfirmation = false - @State private var errorMessage: String? + @State private var shareErrorMessage: String? @State private var didCopy = false + @State private var members: [OutlineDocumentMember] = [] + @State private var isLoadingMembers = false + @State private var isShowingAddPerson = false + @State private var userSearchQuery = "" + @State private var userSearchResults: [OutlineUser] = [] + @State private var isSearchingUsers = false + @State private var selectedPermission = "read" + @State private var isAddingUser = false + @State private var actionErrorMessage: String? + var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -28,67 +38,21 @@ struct DocumentShareSheet: View { Button("Done") { dismiss() } } - if isLoading { - ProgressView().frame(maxWidth: .infinity) - } else if let errorMessage { - Text(errorMessage) - .font(.callout) - .foregroundStyle(.red) - } else if let share { - if let url = share.url { - HStack { - Text(url) - .font(.callout) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - copyLink(url) - } label: { - Image(systemName: didCopy ? "checkmark" : "doc.on.doc") - } - .buttonStyle(.plain) - } - .padding(8) - .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) - } + shareLinkSection - Toggle( - "Published — accessible without sign-in", - isOn: Binding( - get: { share.published }, - set: { newValue in Task { await setPublished(newValue) } } - ) - ) - .disabled(isUpdating) + Divider() - VStack(alignment: .leading, spacing: 4) { - Text("Public page title") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Uses the document's title if empty", text: $titleOverride) - .textFieldStyle(.roundedBorder) - .disabled(isUpdating) - .onSubmit { - Task { await setTitle(titleOverride) } - } - } - - Divider() - - Button("Revoke Link", role: .destructive) { - isShowingRevokeConfirmation = true - } - .disabled(isRevoking) - } else { - Button("Create Share Link") { - Task { await create() } - } - } + peopleSection } .padding(20) .frame(width: 380) - .task { await load() } + .task { await loadShare() } + .task { await loadMembers() } + .task(id: userSearchQuery) { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + await searchUsers(userSearchQuery) + } .confirmationDialog( "Revoke this share link?", isPresented: $isShowingRevokeConfirmation, @@ -101,6 +65,158 @@ struct DocumentShareSheet: View { } message: { Text("Anyone using this link will no longer be able to access the document.") } + .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { + Button("OK") { actionErrorMessage = nil } + } message: { + Text(actionErrorMessage ?? "") + } + } + + @ViewBuilder + private var shareLinkSection: some View { + if isLoadingShare { + ProgressView().frame(maxWidth: .infinity) + } else if let shareErrorMessage { + Text(shareErrorMessage) + .font(.callout) + .foregroundStyle(.red) + } else if let share { + if let url = share.url { + HStack { + Text(url) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + copyLink(url) + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + } + .buttonStyle(.plain) + } + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Public page title") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Uses the document's title if empty", text: $titleOverride) + .textFieldStyle(.roundedBorder) + .disabled(isUpdatingShare) + .onSubmit { + Task { await setTitle(titleOverride) } + } + } + + Button("Revoke Link", role: .destructive) { + isShowingRevokeConfirmation = true + } + .disabled(isRevoking) + } else { + Button("Create Share Link") { + Task { await create() } + } + } + } + + @ViewBuilder + private var peopleSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("People with access") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { + isShowingAddPerson.toggle() + } label: { + Image(systemName: "person.badge.plus") + } + .buttonStyle(.plain) + .help("Add a person") + } + + if isShowingAddPerson { + addPersonSection + } + + if isLoadingMembers { + ProgressView().controlSize(.small) + } else if members.isEmpty { + Text("No one else has explicit access yet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(members) { member in + memberRow(member) + } + } + } + } + + private var addPersonSection: some View { + VStack(alignment: .leading, spacing: 6) { + TextField("Search people by name or email", text: $userSearchQuery) + .textFieldStyle(.roundedBorder) + + Picker("Permission", selection: $selectedPermission) { + Text("Can view").tag("read") + Text("Can edit").tag("read_write") + } + .pickerStyle(.segmented) + .labelsHidden() + + if isSearchingUsers { + ProgressView().controlSize(.small) + } else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if userSearchResults.isEmpty { + Text("No matches.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(userSearchResults) { user in + Button { + Task { await addUser(user) } + } label: { + HStack { + Text(user.name) + .font(.callout) + Spacer() + Image(systemName: "plus.circle") + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isAddingUser) + } + } + } + } + .padding(8) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) + } + + private func memberRow(_ member: OutlineDocumentMember) -> some View { + HStack { + Text(member.name) + .font(.callout) + Spacer() + if let permission = member.permission { + Text(permission == "read_write" ? "Can edit" : "Can view") + .font(.caption) + .foregroundStyle(.secondary) + } + Button { + Task { await removeUser(member) } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } } private func copyLink(_ url: String) { @@ -114,38 +230,25 @@ struct DocumentShareSheet: View { } } - private func load() async { - isLoading = true - defer { isLoading = false } + private func loadShare() async { + isLoadingShare = true + defer { isLoadingShare = false } do { share = try await apiClient.shareInfo(documentId: documentId) titleOverride = share?.title ?? "" } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") + shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.") } } private func create() async { - isLoading = true - defer { isLoading = false } + isLoadingShare = true + defer { isLoadingShare = false } do { share = try await apiClient.createShare(CreateShareRequest(documentId: documentId)) titleOverride = share?.title ?? "" } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") - } - } - - private func setPublished(_ published: Bool) async { - guard let share else { return } - isUpdating = true - defer { isUpdating = false } - do { - self.share = try await apiClient.updateShare( - UpdateShareRequest(id: share.id, published: published, title: share.title) - ) - } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") } } @@ -153,14 +256,14 @@ struct DocumentShareSheet: View { guard let share else { return } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed != (share.title ?? "") else { return } - isUpdating = true - defer { isUpdating = false } + isUpdatingShare = true + defer { isUpdatingShare = false } do { self.share = try await apiClient.updateShare( UpdateShareRequest(id: share.id, published: share.published, title: trimmed) ) } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.") } } @@ -173,7 +276,49 @@ struct DocumentShareSheet: View { self.share = nil titleOverride = "" } catch { - errorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.") + } + } + + private func loadMembers() async { + isLoadingMembers = true + defer { isLoadingMembers = false } + members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? [] + } + + private func searchUsers(_ query: String) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + userSearchResults = [] + return + } + isSearchingUsers = true + defer { isSearchingUsers = false } + userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? [] + } + + private func addUser(_ user: OutlineUser) async { + isAddingUser = true + defer { isAddingUser = false } + do { + _ = try await apiClient.addDocumentUser( + AddDocumentUserRequest(id: documentId, userId: user.id, permission: selectedPermission) + ) + userSearchQuery = "" + userSearchResults = [] + isShowingAddPerson = false + await loadMembers() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add this person.") + } + } + + private func removeUser(_ member: OutlineDocumentMember) async { + do { + try await apiClient.removeDocumentUser(RemoveDocumentUserRequest(id: documentId, userId: member.id)) + await loadMembers() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this person.") } } } From 9ca1c77bb9aa8eafb1c2a9bafcba5e7927b07843 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 22:06:04 +0100 Subject: [PATCH 5/6] refactor(share): popover instead of modal sheet, redesigned layout Share button now opens a popover anchored to the toolbar icon instead of a full modal window. Redesigned the content as a narrow vertical card (icon section headers, avatar-initial rows for members, link card with collapsible title override) sized to fit the People section without scrolling in the common case. --- .../Collections/DocumentReaderView.swift | 6 +- .../Collections/DocumentShareSheet.swift | 243 ++++++++++++------ 2 files changed, 168 insertions(+), 81 deletions(-) diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index e46b20c..d99df2d 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -111,6 +111,9 @@ struct DocumentReaderView: View { Image(systemName: "square.and.arrow.up") } .help("Share") + .popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) { + DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) + } Button { Task { await viewModel.toggleEditing() } @@ -215,9 +218,6 @@ struct DocumentReaderView: View { .sheet(isPresented: $isShowingSearchSheet) { DocumentSearchSheet(apiClient: apiClient, document: document) } - .sheet(isPresented: $isShowingShareSheet) { - DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) - } .sheet(isPresented: $isShowingNewDocumentSheet) { NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in onDocumentCreated() diff --git a/Outpost/Features/Collections/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift index 261184b..d259b34 100644 --- a/Outpost/Features/Collections/DocumentShareSheet.swift +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -3,10 +3,12 @@ import AppKit import SwiftUI import OutlineKit +/// Content of the Share popover anchored to the reader toolbar's Share +/// button (see `DocumentReaderView`'s `.popover(isPresented:)`). Was a +/// modal `.sheet` originally — moved to a popover so it reads as "options +/// for this button" instead of interrupting the whole window. @MainActor struct DocumentShareSheet: View { - @Environment(\.dismiss) private var dismiss - let apiClient: OutlineAPIClient let documentId: String @@ -16,6 +18,7 @@ struct DocumentShareSheet: View { @State private var isUpdatingShare = false @State private var isRevoking = false @State private var isShowingRevokeConfirmation = false + @State private var isShowingTitleField = false @State private var shareErrorMessage: String? @State private var didCopy = false @@ -30,22 +33,25 @@ struct DocumentShareSheet: View { @State private var actionErrorMessage: String? var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Share") - .font(.headline) - Spacer() - Button("Done") { dismiss() } - } - - shareLinkSection + VStack(alignment: .leading, spacing: 0) { + Text("Share") + .font(.headline) + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 10) Divider() - peopleSection + ScrollView { + VStack(alignment: .leading, spacing: 18) { + shareLinkSection + peopleSection + } + .padding(16) + } + .frame(minHeight: 150, maxHeight: 900) } - .padding(20) - .frame(width: 380) + .frame(width: 280) .task { await loadShare() } .task { await loadMembers() } .task(id: userSearchQuery) { @@ -72,70 +78,105 @@ struct DocumentShareSheet: View { } } + // MARK: - Link section + @ViewBuilder private var shareLinkSection: some View { - if isLoadingShare { - ProgressView().frame(maxWidth: .infinity) - } else if let shareErrorMessage { - Text(shareErrorMessage) - .font(.callout) - .foregroundStyle(.red) - } else if let share { - if let url = share.url { - HStack { - Text(url) - .font(.callout) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - copyLink(url) - } label: { - Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + VStack(alignment: .leading, spacing: 8) { + sectionHeader(icon: "link", title: "Public Link") + + if isLoadingShare { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .center) + } else if let shareErrorMessage { + Text(shareErrorMessage) + .font(.callout) + .foregroundStyle(.red) + } else if let share { + VStack(alignment: .leading, spacing: 6) { + if let url = share.url { + HStack(spacing: 8) { + Image(systemName: "globe") + .foregroundStyle(.secondary) + .font(.callout) + Text(url) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Button { + copyLink(url) + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + .font(.callout) + } + .buttonStyle(.plain) + .foregroundStyle(didCopy ? .green : .secondary) + .help("Copy link") + } + } + + if isShowingTitleField { + TextField("Public page title", text: $titleOverride) + .textFieldStyle(.roundedBorder) + .font(.callout) + .disabled(isUpdatingShare) + .onSubmit { + Task { await setTitle(titleOverride) } + } + } + } + .padding(10) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) + + HStack(spacing: 12) { + Button(isShowingTitleField ? "Hide title field" : "Set public title") { + isShowingTitleField.toggle() } .buttonStyle(.plain) - } - .padding(8) - .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) - } - - VStack(alignment: .leading, spacing: 4) { - Text("Public page title") .font(.caption) .foregroundStyle(.secondary) - TextField("Uses the document's title if empty", text: $titleOverride) - .textFieldStyle(.roundedBorder) - .disabled(isUpdatingShare) - .onSubmit { - Task { await setTitle(titleOverride) } - } - } - Button("Revoke Link", role: .destructive) { - isShowingRevokeConfirmation = true - } - .disabled(isRevoking) - } else { - Button("Create Share Link") { - Task { await create() } + Spacer() + + Button("Revoke", role: .destructive) { + isShowingRevokeConfirmation = true + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.red) + .disabled(isRevoking) + } + } else { + Button { + Task { await create() } + } label: { + Label("Create Share Link", systemImage: "link.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) } } } + // MARK: - People section + @ViewBuilder private var peopleSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Text("People with access") - .font(.caption) - .foregroundStyle(.secondary) + sectionHeader(icon: "person.2", title: "People with Access") Spacer() Button { isShowingAddPerson.toggle() } label: { - Image(systemName: "person.badge.plus") + Image(systemName: isShowingAddPerson ? "xmark.circle.fill" : "person.badge.plus") + .font(.callout) } .buttonStyle(.plain) + .foregroundStyle(.secondary) .help("Add a person") } @@ -144,21 +185,37 @@ struct DocumentShareSheet: View { } if isLoadingMembers { - ProgressView().controlSize(.small) + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .center) } else if members.isEmpty { Text("No one else has explicit access yet.") .font(.callout) .foregroundStyle(.secondary) } else { - ForEach(members) { member in - memberRow(member) + VStack(spacing: 2) { + ForEach(members) { member in + memberRow(member) + } } } } } + private func sectionHeader(icon: String, title: String) -> some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.secondary) + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + } + private var addPersonSection: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { TextField("Search people by name or email", text: $userSearchQuery) .textFieldStyle(.roundedBorder) @@ -170,44 +227,55 @@ struct DocumentShareSheet: View { .labelsHidden() if isSearchingUsers { - ProgressView().controlSize(.small) + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .center) } else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if userSearchResults.isEmpty { Text("No matches.") .font(.callout) .foregroundStyle(.secondary) } else { - ForEach(userSearchResults) { user in - Button { - Task { await addUser(user) } - } label: { - HStack { - Text(user.name) - .font(.callout) - Spacer() - Image(systemName: "plus.circle") + VStack(spacing: 2) { + ForEach(userSearchResults) { user in + Button { + Task { await addUser(user) } + } label: { + HStack(spacing: 8) { + avatar(for: user.name) + Text(user.name) + .font(.callout) + Spacer(minLength: 0) + Image(systemName: "plus.circle") + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + .padding(.vertical, 4) } - .contentShape(Rectangle()) + .buttonStyle(.plain) + .disabled(isAddingUser) } - .buttonStyle(.plain) - .disabled(isAddingUser) } } } } - .padding(8) - .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6)) + .padding(10) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) } private func memberRow(_ member: OutlineDocumentMember) -> some View { - HStack { + HStack(spacing: 8) { + avatar(for: member.name) Text(member.name) .font(.callout) - Spacer() + Spacer(minLength: 0) if let permission = member.permission { Text(permission == "read_write" ? "Can edit" : "Can view") .font(.caption) .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.fill.tertiary, in: Capsule()) } Button { Task { await removeUser(member) } @@ -217,6 +285,24 @@ struct DocumentShareSheet: View { } .buttonStyle(.plain) } + .padding(.vertical, 4) + } + + private func avatar(for name: String) -> some View { + Circle() + .fill(.fill.secondary) + .frame(width: 22, height: 22) + .overlay { + Text(initials(for: name)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + + private func initials(for name: String) -> String { + let parts = name.split(separator: " ").prefix(2) + let letters = parts.compactMap { $0.first } + return letters.isEmpty ? "?" : String(letters).uppercased() } private func copyLink(_ url: String) { @@ -275,6 +361,7 @@ struct DocumentShareSheet: View { try await apiClient.revokeShare(id: share.id) self.share = nil titleOverride = "" + isShowingTitleField = false } catch { actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't revoke this share link.") } From a870e99404f8e7d5740b9d9b0c6fd5c8841e0df0 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 22:08:19 +0100 Subject: [PATCH 6/6] chore: bump version to 0.0.2 --- Outpost.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Outpost.xcodeproj/project.pbxproj b/Outpost.xcodeproj/project.pbxproj index 81fd07e..82b374b 100644 --- a/Outpost.xcodeproj/project.pbxproj +++ b/Outpost.xcodeproj/project.pbxproj @@ -408,7 +408,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.1; + MARKETING_VERSION = 0.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -453,7 +453,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.1; + MARKETING_VERSION = 0.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES;