Merge pull request 'Document sharing: fix decode failures, replace broken Published toggle with real permissions' (#5) from fix/document-sharing into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -35,6 +35,8 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
|
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
|
||||||
func shareInfo(documentId: String) async throws -> OutlineShare?
|
func shareInfo(documentId: String) async throws -> OutlineShare?
|
||||||
func updateShare(_ request: UpdateShareRequest) 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.
|
/// See `OutlinePin` — best-effort, not in the vendored spec.
|
||||||
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
|
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
|
||||||
func listPins(_ request: ListPinsRequest) 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`.
|
/// Historical view records, not live presence. Backed by `views.list`.
|
||||||
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
|
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 listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
||||||
func collectionInfo(id: String) async throws -> OutlineCollection
|
func collectionInfo(id: String) async throws -> OutlineCollection
|
||||||
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
|
func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection
|
||||||
|
|||||||
@@ -121,17 +121,27 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
public func shareInfo(documentId: String) async throws -> OutlineShare? {
|
||||||
do {
|
// Real shape confirmed against a live server: `data` is
|
||||||
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
|
// `{ shares: [...] }`, not the bare share object the docs imply —
|
||||||
} catch OutlineAPIError.notFound {
|
// same pattern as `pins.list`. A document could in principle have
|
||||||
return nil
|
// 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 {
|
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
|
||||||
try await post("shares.update", body: request)
|
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 {
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
try await post("pins.create", body: request)
|
try await post("pins.create", body: request)
|
||||||
}
|
}
|
||||||
@@ -165,6 +175,22 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await post("views.list", body: request)
|
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] {
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
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<Body: Encodable, Response: Decodable>(_ 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<Response>.self, from: data).data
|
||||||
|
} catch {
|
||||||
|
throw OutlineAPIError.decoding(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
/// For endpoints shaped `{ "success": true }` instead of `{ "data": ... }`
|
||||||
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
/// (e.g. `collections.delete`) — `post(_:body:)`'s envelope decode doesn't fit.
|
||||||
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
private func postForSuccess<Body: Encodable>(_ path: String, body: Body) async throws {
|
||||||
@@ -304,6 +376,10 @@ private struct PinsListPayload: Decodable {
|
|||||||
let documents: [OutlineDocument]
|
let documents: [OutlineDocument]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SharesInfoPayload: Decodable {
|
||||||
|
let shares: [OutlineShare]
|
||||||
|
}
|
||||||
|
|
||||||
private struct ListStarsResponse: Decodable {
|
private struct ListStarsResponse: Decodable {
|
||||||
let stars: [OutlineStar]
|
let stars: [OutlineStar]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 `<resource>.<verb>` 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?
|
||||||
|
}
|
||||||
@@ -1,10 +1,41 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// A public share link for a document or collection, backed by `shares.*`.
|
/// 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 struct OutlineShare: Decodable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
|
public let published: Bool
|
||||||
|
|
||||||
public let documentId: String?
|
public let documentId: String?
|
||||||
public let collectionId: String?
|
public let collectionId: String?
|
||||||
public let url: String
|
public let documentTitle: String?
|
||||||
public let published: Bool
|
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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,16 @@ import Foundation
|
|||||||
public struct UpdateShareRequest: Encodable, Sendable {
|
public struct UpdateShareRequest: Encodable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let published: Bool
|
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.id = id
|
||||||
self.published = published
|
self.published = published
|
||||||
|
self.title = title
|
||||||
|
self.iconUrl = iconUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -669,6 +669,117 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
|
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 {
|
func testShareInfoReturnsNilOnNotFound() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.statusCode = 404
|
httpClient.statusCode = 404
|
||||||
@@ -687,6 +798,24 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertNil(share)
|
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 {
|
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.responseData = """
|
httpClient.responseData = """
|
||||||
@@ -781,6 +910,89 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
|
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 {
|
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
let client = LiveOutlineAPIClient(
|
let client = LiveOutlineAPIClient(
|
||||||
|
|||||||
@@ -408,7 +408,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -453,7 +453,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 0.0.1;
|
MARKETING_VERSION = 0.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -316,8 +316,9 @@ private struct DocumentNodeRow: View {
|
|||||||
renameText = node.document.title
|
renameText = node.document.title
|
||||||
isShowingRenameAlert = true
|
isShowingRenameAlert = true
|
||||||
}
|
}
|
||||||
// Sharing/membership management is its own subsystem, not a one-off
|
// DocumentShareSheet now has a real "People with access" section —
|
||||||
// action — deferred rather than half-built here.
|
// 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…") {}
|
Button("Permissions…") {}
|
||||||
.disabled(true)
|
.disabled(true)
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ struct DocumentReaderView: View {
|
|||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.help("Share")
|
.help("Share")
|
||||||
|
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
||||||
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task { await viewModel.toggleEditing() }
|
Task { await viewModel.toggleEditing() }
|
||||||
@@ -215,9 +218,6 @@ struct DocumentReaderView: View {
|
|||||||
.sheet(isPresented: $isShowingSearchSheet) {
|
.sheet(isPresented: $isShowingSearchSheet) {
|
||||||
DocumentSearchSheet(apiClient: apiClient, document: document)
|
DocumentSearchSheet(apiClient: apiClient, document: document)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $isShowingShareSheet) {
|
|
||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
||||||
NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
|
NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
|
||||||
onDocumentCreated()
|
onDocumentCreated()
|
||||||
@@ -299,10 +299,12 @@ struct DocumentReaderView: View {
|
|||||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||||
Task { await viewModel.toggleEditing() }
|
Task { await viewModel.toggleEditing() }
|
||||||
}
|
}
|
||||||
// Sharing/membership management is its own subsystem, not a one-off
|
// Membership management now lives in DocumentShareSheet's "People
|
||||||
// action — deferred rather than half-built here.
|
// with access" section, alongside the share link — same sheet,
|
||||||
Button("Permissions…") {}
|
// same isShowingShareSheet state.
|
||||||
.disabled(true)
|
Button("Permissions…") {
|
||||||
|
isShowingShareSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ final class DocumentReaderViewModel {
|
|||||||
private var pinId: String?
|
private var pinId: String?
|
||||||
private(set) var isSubscribed = false
|
private(set) var isSubscribed = false
|
||||||
private var subscriptionId: String?
|
private var subscriptionId: String?
|
||||||
private(set) var share: OutlineShare?
|
|
||||||
|
|
||||||
/// `nil` until checked. Inferred from whether `documents.insights`
|
/// `nil` until checked. Inferred from whether `documents.insights`
|
||||||
/// succeeds or fails — `insightsEnabled` isn't readable back off
|
/// 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 {
|
func loadInsightsEnabledState() async {
|
||||||
do {
|
do {
|
||||||
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
|
_ = 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.
|
/// Turning editing off saves; turning it on is just a mode switch.
|
||||||
func toggleEditing() async {
|
func toggleEditing() async {
|
||||||
guard isEditing else {
|
guard isEditing else {
|
||||||
|
|||||||
@@ -3,68 +3,306 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OutlineKit
|
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
|
@MainActor
|
||||||
struct DocumentShareSheet: View {
|
struct DocumentShareSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
let documentId: String
|
let documentId: String
|
||||||
|
|
||||||
@State private var share: OutlineShare?
|
@State private var share: OutlineShare?
|
||||||
@State private var isLoading = false
|
@State private var titleOverride = ""
|
||||||
@State private var isUpdating = false
|
@State private var isLoadingShare = false
|
||||||
@State private var errorMessage: String?
|
@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
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack {
|
|
||||||
Text("Share")
|
Text("Share")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Spacer()
|
.padding(.horizontal, 16)
|
||||||
Button("Done") { dismiss() }
|
.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 ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if isLoading {
|
// MARK: - Link section
|
||||||
ProgressView().frame(maxWidth: .infinity)
|
|
||||||
} else if let errorMessage {
|
@ViewBuilder
|
||||||
Text(errorMessage)
|
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)
|
.font(.callout)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
} else if let share {
|
} else if let share {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(share.url)
|
if let url = share.url {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "globe")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.callout)
|
||||||
|
Text(url)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
Spacer()
|
Spacer(minLength: 0)
|
||||||
Button {
|
Button {
|
||||||
copyLink(share.url)
|
copyLink(url)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
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)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(.vertical, 4)
|
||||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
|
}
|
||||||
|
|
||||||
Toggle(
|
private func avatar(for name: String) -> some View {
|
||||||
"Published — accessible without sign-in",
|
Circle()
|
||||||
isOn: Binding(
|
.fill(.fill.secondary)
|
||||||
get: { share.published },
|
.frame(width: 22, height: 22)
|
||||||
set: { newValue in Task { await setPublished(newValue) } }
|
.overlay {
|
||||||
)
|
Text(initials(for: name))
|
||||||
)
|
.font(.system(size: 10, weight: .semibold))
|
||||||
.disabled(isUpdating)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
|
||||||
Button("Create Share Link") {
|
|
||||||
Task { await create() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(20)
|
private func initials(for name: String) -> String {
|
||||||
.frame(width: 380)
|
let parts = name.split(separator: " ").prefix(2)
|
||||||
.task { await load() }
|
let letters = parts.compactMap { $0.first }
|
||||||
|
return letters.isEmpty ? "?" : String(letters).uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copyLink(_ url: String) {
|
private func copyLink(_ url: String) {
|
||||||
@@ -78,34 +316,96 @@ struct DocumentShareSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func loadShare() async {
|
||||||
isLoading = true
|
isLoadingShare = true
|
||||||
defer { isLoading = false }
|
defer { isLoadingShare = false }
|
||||||
do {
|
do {
|
||||||
share = try await apiClient.shareInfo(documentId: documentId)
|
share = try await apiClient.shareInfo(documentId: documentId)
|
||||||
|
titleOverride = share?.title ?? ""
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
shareErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func create() async {
|
private func create() async {
|
||||||
isLoading = true
|
isLoadingShare = true
|
||||||
defer { isLoading = false }
|
defer { isLoadingShare = false }
|
||||||
do {
|
do {
|
||||||
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
|
||||||
|
titleOverride = share?.title ?? ""
|
||||||
} catch {
|
} 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 }
|
guard let share else { return }
|
||||||
isUpdating = true
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
defer { isUpdating = false }
|
guard trimmed != (share.title ?? "") else { return }
|
||||||
|
isUpdatingShare = true
|
||||||
|
defer { isUpdatingShare = false }
|
||||||
do {
|
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 {
|
} 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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user