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 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]
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 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,111 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
|
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 {
|
func testShareInfoReturnsNilOnNotFound() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
httpClient.statusCode = 404
|
httpClient.statusCode = 404
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ struct DocumentShareSheet: View {
|
|||||||
let documentId: String
|
let documentId: String
|
||||||
|
|
||||||
@State private var share: OutlineShare?
|
@State private var share: OutlineShare?
|
||||||
|
@State private var titleOverride = ""
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var isUpdating = false
|
@State private var isUpdating = false
|
||||||
|
@State private var isRevoking = false
|
||||||
|
@State private var isShowingRevokeConfirmation = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var didCopy = false
|
@State private var didCopy = false
|
||||||
|
|
||||||
@@ -32,14 +35,15 @@ struct DocumentShareSheet: View {
|
|||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
} else if let share {
|
} else if let share {
|
||||||
|
if let url = share.url {
|
||||||
HStack {
|
HStack {
|
||||||
Text(share.url)
|
Text(url)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
copyLink(share.url)
|
copyLink(url)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
|
||||||
}
|
}
|
||||||
@@ -47,6 +51,7 @@ struct DocumentShareSheet: View {
|
|||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
|
||||||
Toggle(
|
Toggle(
|
||||||
"Published — accessible without sign-in",
|
"Published — accessible without sign-in",
|
||||||
@@ -56,6 +61,25 @@ struct DocumentShareSheet: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.disabled(isUpdating)
|
.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 {
|
} else {
|
||||||
Button("Create Share Link") {
|
Button("Create Share Link") {
|
||||||
Task { await create() }
|
Task { await create() }
|
||||||
@@ -65,6 +89,18 @@ struct DocumentShareSheet: View {
|
|||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
.task { await load() }
|
.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) {
|
private func copyLink(_ url: String) {
|
||||||
@@ -83,6 +119,7 @@ struct DocumentShareSheet: View {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = 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.")
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
|
||||||
}
|
}
|
||||||
@@ -93,6 +130,7 @@ struct DocumentShareSheet: View {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = 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.")
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
|
||||||
}
|
}
|
||||||
@@ -103,10 +141,40 @@ struct DocumentShareSheet: View {
|
|||||||
isUpdating = true
|
isUpdating = true
|
||||||
defer { isUpdating = false }
|
defer { isUpdating = 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: published, title: share.title)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
|
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
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user