From e8a72014b968eb151b9a06c398080fca5cda5a10 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 02:32:05 +0100 Subject: [PATCH] feat(collections): reflect starred state for documents and collections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Star/Unstar fired the API call but nothing showed it took effect — Document/Collection have no isStarred field, stars.list is the only source of truth, so this adds a shared StarStore (one stars.list fetch, optimistic toggle/rollback on star/unstar) injected at the app root and read by the sidebar tree rows, collection header row, and DocumentRowView (document lists, search results, reader sub-documents). Context menu labels now read "Unstar" once starred, and starred rows show a filled star. OutlineKit: stars.list / stars.delete, with test coverage. --- .../OutlineKit/Core/OutlineAPIClient.swift | 3 + .../OutlineKit/LiveOutlineAPIClient.swift | 17 +++++ .../Requests/ListStarsRequest.swift | 11 +++ .../LiveOutlineAPIClientTests.swift | 45 +++++++++++ .../CollectionDocumentsOutline.swift | 14 +++- .../Collections/CollectionTreeRow.swift | 15 +++- .../Collections/DocumentRowView.swift | 8 ++ Outpost/Root/RootView.swift | 9 +++ Outpost/Support/StarStore.swift | 76 +++++++++++++++++++ 9 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Requests/ListStarsRequest.swift create mode 100644 Outpost/Support/StarStore.swift diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 092493d..5f2f8fb 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -37,6 +37,9 @@ public protocol OutlineAPIClient: Sendable { /// for completion or downloading the resulting file. func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar + /// Every star across both documents and collections for the current user. Backed by `stars.list`. + func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] + func deleteStar(id: String) async throws func currentUser() async throws -> OutlineUser } diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 7e36512..508f749 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -133,6 +133,15 @@ public actor LiveOutlineAPIClient: OutlineAPIClient { try await post("stars.create", body: request) } + public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { + let result: ListStarsResponse = try await post("stars.list", body: request) + return result.stars + } + + public func deleteStar(id: String) async throws { + try await postForSuccess("stars.delete", body: StarIDParams(id: id)) + } + public func currentUser() async throws -> OutlineUser { try await post("users.info", body: EmptyParams()) } @@ -233,6 +242,14 @@ private struct DuplicateDocumentResponse: Decodable { let documents: [OutlineDocument] } +private struct ListStarsResponse: Decodable { + let stars: [OutlineStar] +} + +private struct StarIDParams: Encodable { + let id: String +} + private struct MoveDocumentResponse: Decodable { let documents: [OutlineDocument]? let collections: [OutlineCollection]? diff --git a/OutlineKit/Sources/OutlineKit/Requests/ListStarsRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/ListStarsRequest.swift new file mode 100644 index 0000000..b27f770 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/ListStarsRequest.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct ListStarsRequest: Encodable, Sendable { + public let offset: Int + public let limit: Int + + public init(offset: Int = 0, limit: Int = 100) { + self.offset = offset + self.limit = limit + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 671334e..7fcf1b6 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -539,6 +539,51 @@ final class LiveOutlineAPIClientTests: XCTestCase { XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.insights") } + func testListStarsDecodesStarsArray() async throws { + let httpClient = MockHTTPClient() + httpClient.responseData = """ + { + "data": { + "stars": [ + { "id": "star-1", "collectionId": "col-1", "documentId": null }, + { "id": "star-2", "collectionId": null, "documentId": "doc-1" } + ], + "documents": [] + } + } + """.data(using: .utf8)! + + let client = LiveOutlineAPIClient( + configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!), + tokenStore: StaticTokenStore(), + httpClient: httpClient + ) + + let stars = try await client.listStars(ListStarsRequest()) + + XCTAssertEqual(stars.count, 2) + XCTAssertEqual(stars.first?.collectionId, "col-1") + XCTAssertEqual(stars.last?.documentId, "doc-1") + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.list") + } + + func testDeleteStarSucceedsOnSuccessTrue() 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.deleteStar(id: "star-1") + + XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.delete") + } + func testMissingTokenThrowsTokenUnavailable() async throws { let httpClient = MockHTTPClient() let client = LiveOutlineAPIClient( diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 4854976..6db03f2 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -73,6 +73,8 @@ struct CollectionDocumentsOutline: View { /// the ViewBridge/`nw_connection` console spam (and worse, silent failures). @MainActor private struct DocumentNodeRow: View { + @Environment(StarStore.self) private var starStore + let apiClient: OutlineAPIClient let node: DocumentNode let depth: Int @@ -142,6 +144,12 @@ private struct DocumentNodeRow: View { .lineLimit(1) Spacer(minLength: 0) + + if starStore.isStarred(documentId: node.document.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } } .contentShape(Rectangle()) } @@ -248,7 +256,7 @@ private struct DocumentNodeRow: View { @ViewBuilder private var contextMenuContent: some View { - Button("Star") { + Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") { Task { await star() } } // No `subscriptions.*` endpoint in the API — nothing to back this with. @@ -337,9 +345,9 @@ private struct DocumentNodeRow: View { private func star() async { do { - _ = try await apiClient.starDocument(StarDocumentRequest(documentId: node.document.id)) + try await starStore.toggleDocument(node.document.id, apiClient: apiClient) } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this document.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.") } } diff --git a/Outpost/Features/Collections/CollectionTreeRow.swift b/Outpost/Features/Collections/CollectionTreeRow.swift index 953a360..1bd159e 100644 --- a/Outpost/Features/Collections/CollectionTreeRow.swift +++ b/Outpost/Features/Collections/CollectionTreeRow.swift @@ -2,7 +2,10 @@ import SwiftUI import OutlineKit +@MainActor struct CollectionTreeRow: View { + @Environment(StarStore.self) private var starStore + let apiClient: OutlineAPIClient let collection: OutlineCollection let isExpanded: Bool @@ -34,6 +37,12 @@ struct CollectionTreeRow: View { CollectionRowView(collection: collection) Spacer(minLength: 0) + + if starStore.isStarred(collectionId: collection.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } } .padding(.vertical, 4) .padding(.horizontal, 6) @@ -87,7 +96,7 @@ struct CollectionTreeRow: View { @ViewBuilder private var contextMenuContent: some View { - Button("Star") { + Button(starStore.isStarred(collectionId: collection.id) ? "Unstar" : "Star") { Task { await star() } } @@ -133,9 +142,9 @@ struct CollectionTreeRow: View { private func star() async { do { - _ = try await apiClient.starCollection(StarCollectionRequest(collectionId: collection.id)) + try await starStore.toggleCollection(collection.id, apiClient: apiClient) } catch { - actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this collection.") + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this collection.") } } diff --git a/Outpost/Features/Collections/DocumentRowView.swift b/Outpost/Features/Collections/DocumentRowView.swift index 39d8eb2..4164307 100644 --- a/Outpost/Features/Collections/DocumentRowView.swift +++ b/Outpost/Features/Collections/DocumentRowView.swift @@ -2,6 +2,8 @@ import SwiftUI import OutlineKit struct DocumentRowView: View { + @Environment(StarStore.self) private var starStore + let document: OutlineDocument var body: some View { @@ -13,6 +15,12 @@ struct DocumentRowView: View { Text(document.title.isEmpty ? "Untitled" : document.title) .font(.body) .lineLimit(1) + + if starStore.isStarred(documentId: document.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } } Text(document.updatedAt, format: .relative(presentation: .named)) .font(.caption) diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index 8ff7c00..5b25277 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -4,6 +4,7 @@ import OutlineKit struct RootView: View { @Environment(SessionStore.self) private var session @State private var welcomeName: String? + @State private var starStore = StarStore() var body: some View { ZStack { @@ -19,10 +20,18 @@ struct RootView: View { .zIndex(1) } } + .environment(starStore) .animation(.easeInOut(duration: 0.45), value: welcomeName != nil) .task { await session.refreshTeamInfoIfNeeded() } + .task(id: session.isSignedIn) { + if session.isSignedIn, let apiClient = session.apiClient { + await starStore.load(apiClient: apiClient) + } else { + starStore.reset() + } + } } private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { diff --git a/Outpost/Support/StarStore.swift b/Outpost/Support/StarStore.swift new file mode 100644 index 0000000..05bac17 --- /dev/null +++ b/Outpost/Support/StarStore.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation +import OutlineKit + +/// Shared starred-state cache, one `stars.list` fetch shared across every +/// sidebar row and reader instead of each row independently guessing at its +/// own starred state (the API has no `isStarred` field on `Document`/ +/// `Collection` themselves — the only source of truth is the stars list). +@MainActor +@Observable +final class StarStore { + private var documentStars: [String: OutlineStar] = [:] + private var collectionStars: [String: OutlineStar] = [:] + private(set) var isLoaded = false + + func isStarred(documentId: String) -> Bool { + documentStars[documentId] != nil + } + + func isStarred(collectionId: String) -> Bool { + collectionStars[collectionId] != nil + } + + func load(apiClient: OutlineAPIClient) async { + guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return } + documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in + star.documentId.map { ($0, star) } + }) + collectionStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in + star.collectionId.map { ($0, star) } + }) + isLoaded = true + } + + func reset() { + documentStars = [:] + collectionStars = [:] + isLoaded = false + } + + /// Optimistic — the row shows the new state immediately rather than + /// waiting on a re-fetch, then rolls back if the request actually fails. + func toggleDocument(_ documentId: String, apiClient: OutlineAPIClient) async throws { + if let star = documentStars[documentId] { + documentStars.removeValue(forKey: documentId) + do { + try await apiClient.deleteStar(id: star.id) + } catch { + documentStars[documentId] = star + throw error + } + } else { + do { + let star = try await apiClient.starDocument(StarDocumentRequest(documentId: documentId)) + documentStars[documentId] = star + } catch { + throw error + } + } + } + + func toggleCollection(_ collectionId: String, apiClient: OutlineAPIClient) async throws { + if let star = collectionStars[collectionId] { + collectionStars.removeValue(forKey: collectionId) + do { + try await apiClient.deleteStar(id: star.id) + } catch { + collectionStars[collectionId] = star + throw error + } + } else { + let star = try await apiClient.starCollection(StarCollectionRequest(collectionId: collectionId)) + collectionStars[collectionId] = star + } + } +}