fix(shares): match documented API shape, fill in list/revoke, harden share sheet
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.
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user