fix(pins): decode pins.list correctly, distinguish Pin to Home vs Collection

Same root-cause fix as feature/home-page, applied to this branch's
copy of the pin code (which additionally has the sidebar's real
per-collection Pin wiring):

- pins.list's real response is {data: {pins: [...], documents: [...]}},
  not a bare array — confirmed against a live server. Decoding straight
  to [OutlinePin] threw every call; try? swallowed it, so pins never
  showed up (even a doc pinned for real via the web app).
- "Pin to Home" (web's actual label) sends collectionId: null; "Pin to
  Collection" sends a real id — distinct actions. The reader toolbar's
  Pin was sending the doc's own collectionId under a plain "Pin" label,
  silently doing the wrong one. Fixed to nil, relabeled "Pin to Home".
- Sidebar's per-document Pin was already correctly scoped to
  collection.id — relabeled "Pin to Collection" for clarity, no logic
  change.
This commit is contained in:
2026-08-14 16:36:07 +01:00
parent 278e93ddeb
commit ad3e35e28e
8 changed files with 56 additions and 13 deletions
@@ -129,7 +129,12 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
} }
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await post("pins.list", body: request) // `data` here is `{ pins: [...], documents: [...] }`, not a bare
// array confirmed against a live server. Decoding straight to
// `[OutlinePin]` throws on every call, which `try?` at call sites
// swallows silently, so pins never showed up anywhere.
let payload: PinsListPayload = try await post("pins.list", body: request)
return payload.pins
} }
public func deletePin(id: String) async throws { public func deletePin(id: String) async throws {
@@ -286,6 +291,11 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument] let documents: [OutlineDocument]
} }
private struct PinsListPayload: Decodable {
let pins: [OutlinePin]
let documents: [OutlineDocument]
}
private struct ListStarsResponse: Decodable { private struct ListStarsResponse: Decodable {
let stars: [OutlineStar] let stars: [OutlineStar]
} }
@@ -1,11 +1,11 @@
import Foundation import Foundation
/// A document pinned to the top of a collection (or the team home), backed by /// A document pinned to the top of a collection, or to team Home when
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`) /// `collectionId` is `nil`. Backed by `pins.*` not in the vendored OpenAPI
/// that spec has no `Pins` tag at all but the endpoint exists on Outline's /// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed /// confirmed real against a live server's network traffic. `collectionId: nil`
/// from general knowledge of Outline's API, not verified against this spec; /// is what "Pin to Home" actually sends; a non-nil value is "Pin to
/// treat field names as best-effort until confirmed against a live server. /// Collection", a distinct action.
public struct OutlinePin: Decodable, Identifiable, Sendable { public struct OutlinePin: Decodable, Identifiable, Sendable {
public let id: String public let id: String
public let documentId: String public let documentId: String
@@ -1,6 +1,7 @@
import Foundation import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec. /// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
/// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Encodable, Sendable { public struct CreatePinRequest: Encodable, Sendable {
public let documentId: String public let documentId: String
public let collectionId: String? public let collectionId: String?
@@ -1,6 +1,6 @@
import Foundation import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec. /// See `OutlinePin`. `collectionId: nil` lists Home pins only.
public struct ListPinsRequest: Encodable, Sendable { public struct ListPinsRequest: Encodable, Sendable {
public let collectionId: String? public let collectionId: String?
@@ -676,6 +676,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
} }
func testListPinsDecodesNestedPinsArray() async throws {
// Real shape confirmed against a live server: `data` is
// `{ pins: [...], documents: [...] }`, not a bare array.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": {
"pins": [
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
],
"documents": []
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
XCTAssertEqual(pins.first?.id, "pin-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
}
func testCreateSubscriptionDecodesSubscription() async throws { func testCreateSubscriptionDecodesSubscription() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
@@ -330,7 +330,9 @@ private struct DocumentNodeRow: View {
Button("New Document") { Button("New Document") {
Task { await createChildDocument() } Task { await createChildDocument() }
} }
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin" : "Pin") { // Scoped to this collection (`collection.id`) "Pin to Collection",
// distinct from the reader toolbar's "Pin to Home" (collectionId: nil).
Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") {
Task { await togglePin() } Task { await togglePin() }
} }
@@ -317,7 +317,7 @@ struct DocumentReaderView: View {
Button("New Document") { Button("New Document") {
Task { await createChildDocument() } Task { await createChildDocument() }
} }
Button(viewModel.isPinned ? "Unpin" : "Pin") { Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
Task { await togglePin() } Task { await togglePin() }
} }
@@ -81,7 +81,9 @@ final class DocumentReaderViewModel {
} }
func loadPinAndSubscriptionState() async { func loadPinAndSubscriptionState() async {
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)), // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
// Home", not "Pin to Collection" those are distinct on the server.
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
let match = pins.first(where: { $0.documentId == documentId }) { let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true isPinned = true
pinId = match.id pinId = match.id
@@ -125,7 +127,7 @@ final class DocumentReaderViewModel {
throw error throw error
} }
} else { } else {
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId)) let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: nil))
pinId = pin.id pinId = pin.id
isPinned = true isPinned = true
} }