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] {
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 {
@@ -286,6 +291,11 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument]
}
private struct PinsListPayload: Decodable {
let pins: [OutlinePin]
let documents: [OutlineDocument]
}
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
@@ -1,11 +1,11 @@
import Foundation
/// A document pinned to the top of a collection (or the team home), backed by
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
/// that spec has no `Pins` tag at all but the endpoint exists on Outline's
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
/// from general knowledge of Outline's API, not verified against this spec;
/// treat field names as best-effort until confirmed against a live server.
/// A document pinned to the top of a collection, or to team Home when
/// `collectionId` is `nil`. Backed by `pins.*` not in the vendored OpenAPI
/// spec (`docs/reference/outline-openapi`, no `Pins` tag at all), but
/// confirmed real against a live server's network traffic. `collectionId: nil`
/// is what "Pin to Home" actually sends; a non-nil value is "Pin to
/// Collection", a distinct action.
public struct OutlinePin: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String
@@ -1,6 +1,7 @@
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 let documentId: String
public let collectionId: String?
@@ -1,6 +1,6 @@
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 let collectionId: String?
@@ -676,6 +676,34 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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 {
let httpClient = MockHTTPClient()
httpClient.responseData = """
@@ -330,7 +330,9 @@ private struct DocumentNodeRow: View {
Button("New Document") {
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() }
}
@@ -317,7 +317,7 @@ struct DocumentReaderView: View {
Button("New Document") {
Task { await createChildDocument() }
}
Button(viewModel.isPinned ? "Unpin" : "Pin") {
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
Task { await togglePin() }
}
@@ -81,7 +81,9 @@ final class DocumentReaderViewModel {
}
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 }) {
isPinned = true
pinId = match.id
@@ -125,7 +127,7 @@ final class DocumentReaderViewModel {
throw error
}
} 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
isPinned = true
}