feat(collections): reflect starred state for documents and collections

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.
This commit is contained in:
2026-08-14 02:32:05 +01:00
parent 8888865b44
commit e8a72014b9
9 changed files with 192 additions and 6 deletions
@@ -37,6 +37,9 @@ public protocol OutlineAPIClient: Sendable {
/// for completion or downloading the resulting file. /// for completion or downloading the resulting file.
func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation
func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar 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 func currentUser() async throws -> OutlineUser
} }
@@ -133,6 +133,15 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("stars.create", body: request) 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 { public func currentUser() async throws -> OutlineUser {
try await post("users.info", body: EmptyParams()) try await post("users.info", body: EmptyParams())
} }
@@ -233,6 +242,14 @@ private struct DuplicateDocumentResponse: Decodable {
let documents: [OutlineDocument] let documents: [OutlineDocument]
} }
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
private struct StarIDParams: Encodable {
let id: String
}
private struct MoveDocumentResponse: Decodable { private struct MoveDocumentResponse: Decodable {
let documents: [OutlineDocument]? let documents: [OutlineDocument]?
let collections: [OutlineCollection]? let collections: [OutlineCollection]?
@@ -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
}
}
@@ -539,6 +539,51 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.insights") 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 { func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient( let client = LiveOutlineAPIClient(
@@ -73,6 +73,8 @@ struct CollectionDocumentsOutline: View {
/// the ViewBridge/`nw_connection` console spam (and worse, silent failures). /// the ViewBridge/`nw_connection` console spam (and worse, silent failures).
@MainActor @MainActor
private struct DocumentNodeRow: View { private struct DocumentNodeRow: View {
@Environment(StarStore.self) private var starStore
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let node: DocumentNode let node: DocumentNode
let depth: Int let depth: Int
@@ -142,6 +144,12 @@ private struct DocumentNodeRow: View {
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 0) Spacer(minLength: 0)
if starStore.isStarred(documentId: node.document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -248,7 +256,7 @@ private struct DocumentNodeRow: View {
@ViewBuilder @ViewBuilder
private var contextMenuContent: some View { private var contextMenuContent: some View {
Button("Star") { Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") {
Task { await star() } Task { await star() }
} }
// No `subscriptions.*` endpoint in the API nothing to back this with. // No `subscriptions.*` endpoint in the API nothing to back this with.
@@ -337,9 +345,9 @@ private struct DocumentNodeRow: View {
private func star() async { private func star() async {
do { do {
_ = try await apiClient.starDocument(StarDocumentRequest(documentId: node.document.id)) try await starStore.toggleDocument(node.document.id, apiClient: apiClient)
} catch { } catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this document.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.")
} }
} }
@@ -2,7 +2,10 @@
import SwiftUI import SwiftUI
import OutlineKit import OutlineKit
@MainActor
struct CollectionTreeRow: View { struct CollectionTreeRow: View {
@Environment(StarStore.self) private var starStore
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
let collection: OutlineCollection let collection: OutlineCollection
let isExpanded: Bool let isExpanded: Bool
@@ -34,6 +37,12 @@ struct CollectionTreeRow: View {
CollectionRowView(collection: collection) CollectionRowView(collection: collection)
Spacer(minLength: 0) Spacer(minLength: 0)
if starStore.isStarred(collectionId: collection.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
} }
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.horizontal, 6) .padding(.horizontal, 6)
@@ -87,7 +96,7 @@ struct CollectionTreeRow: View {
@ViewBuilder @ViewBuilder
private var contextMenuContent: some View { private var contextMenuContent: some View {
Button("Star") { Button(starStore.isStarred(collectionId: collection.id) ? "Unstar" : "Star") {
Task { await star() } Task { await star() }
} }
@@ -133,9 +142,9 @@ struct CollectionTreeRow: View {
private func star() async { private func star() async {
do { do {
_ = try await apiClient.starCollection(StarCollectionRequest(collectionId: collection.id)) try await starStore.toggleCollection(collection.id, apiClient: apiClient)
} catch { } catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't star this collection.") actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this collection.")
} }
} }
@@ -2,6 +2,8 @@ import SwiftUI
import OutlineKit import OutlineKit
struct DocumentRowView: View { struct DocumentRowView: View {
@Environment(StarStore.self) private var starStore
let document: OutlineDocument let document: OutlineDocument
var body: some View { var body: some View {
@@ -13,6 +15,12 @@ struct DocumentRowView: View {
Text(document.title.isEmpty ? "Untitled" : document.title) Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.body) .font(.body)
.lineLimit(1) .lineLimit(1)
if starStore.isStarred(documentId: document.id) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
} }
Text(document.updatedAt, format: .relative(presentation: .named)) Text(document.updatedAt, format: .relative(presentation: .named))
.font(.caption) .font(.caption)
+9
View File
@@ -4,6 +4,7 @@ import OutlineKit
struct RootView: View { struct RootView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@State private var welcomeName: String? @State private var welcomeName: String?
@State private var starStore = StarStore()
var body: some View { var body: some View {
ZStack { ZStack {
@@ -19,10 +20,18 @@ struct RootView: View {
.zIndex(1) .zIndex(1)
} }
} }
.environment(starStore)
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil) .animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
.task { .task {
await session.refreshTeamInfoIfNeeded() 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) { private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
+76
View File
@@ -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
}
}
}