diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 90d34fb..c1b0eac 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] @@ -46,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 18090e6..13ca7e1 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -121,17 +121,27 @@ 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 - } + // 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 { 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) } @@ -165,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)) } @@ -243,6 +269,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 { @@ -304,6 +376,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/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/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/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/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/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/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..39cb57d 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -669,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") } + 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": { + "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": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } } + ], + "status": 200, + "ok": true + } + """.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?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d") + XCTAssertEqual(share?.published, false) + XCTAssertEqual(share?.views, 0) + XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas") + XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044") + } + + 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 @@ -687,6 +798,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 = """ @@ -781,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.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; 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..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() @@ -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/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..d259b34 100644 --- a/Outpost/Features/Collections/DocumentShareSheet.swift +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -3,68 +3,306 @@ 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 @State private var share: OutlineShare? - @State private var isLoading = false - @State private var isUpdating = false - @State private var errorMessage: String? + @State private var titleOverride = "" + @State private var isLoadingShare = false + @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 - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Share") - .font(.headline) - Spacer() - Button("Done") { dismiss() } - } + @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? - if isLoading { - ProgressView().frame(maxWidth: .infinity) - } else if let errorMessage { - Text(errorMessage) + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Share") + .font(.headline) + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 10) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + shareLinkSection + peopleSection + } + .padding(16) + } + .frame(minHeight: 150, maxHeight: 900) + } + .frame(width: 280) + .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, + 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.") + } + .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { + Button("OK") { actionErrorMessage = nil } + } message: { + Text(actionErrorMessage ?? "") + } + } + + // MARK: - Link section + + @ViewBuilder + private var shareLinkSection: some View { + 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 { - HStack { - Text(share.url) - .font(.callout) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - copyLink(share.url) - } label: { - Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + 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)) + .font(.caption) + .foregroundStyle(.secondary) - Toggle( - "Published — accessible without sign-in", - isOn: Binding( - get: { share.published }, - set: { newValue in Task { await setPublished(newValue) } } - ) - ) - .disabled(isUpdating) + Spacer() + + Button("Revoke", role: .destructive) { + isShowingRevokeConfirmation = true + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.red) + .disabled(isRevoking) + } } else { - Button("Create Share Link") { + 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 { + sectionHeader(icon: "person.2", title: "People with Access") + Spacer() + Button { + isShowingAddPerson.toggle() + } label: { + Image(systemName: isShowingAddPerson ? "xmark.circle.fill" : "person.badge.plus") + .font(.callout) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("Add a person") + } + + if isShowingAddPerson { + addPersonSection + } + + if isLoadingMembers { + 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 { + VStack(spacing: 2) { + ForEach(members) { member in + memberRow(member) + } } } } - .padding(20) - .frame(width: 380) - .task { await load() } + } + + 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: 8) { + 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) + .frame(maxWidth: .infinity, alignment: .center) + } else if !userSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if userSearchResults.isEmpty { + Text("No matches.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + 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) + } + .buttonStyle(.plain) + .disabled(isAddingUser) + } + } + } + } + } + .padding(10) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) + } + + private func memberRow(_ member: OutlineDocumentMember) -> some View { + HStack(spacing: 8) { + avatar(for: member.name) + Text(member.name) + .font(.callout) + 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) } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .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) { @@ -78,34 +316,96 @@ 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.") + shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.") } } - private func setPublished(_ published: Bool) async { + private func setTitle(_ title: String) async { guard let share else { return } - isUpdating = true - defer { isUpdating = false } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed != (share.title ?? "") else { return } + isUpdatingShare = true + defer { isUpdatingShare = 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: 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.") + } + } + + 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 = "" + isShowingTitleField = false + } catch { + 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.") } } }