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:
2026-08-14 17:41:28 +01:00
parent db59e3fbe5
commit 43f9d6052f
8 changed files with 256 additions and 26 deletions
@@ -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