Three parity gaps found reviewing Home against CollectionOverviewView: - The toolbar's contextual search icon rendered on Home but contextualSearchQuery is only ever read by CollectionOverviewView — clicking it and typing did nothing. Hidden specifically on the Home landing page per explicit request (sidebar's global search already covers this); goHome() also clears the stale query/expanded state so it can't leak back in when returning to a collection. - Home had no periodic remote-changes check, unlike CollectionsTreeView and CollectionOverviewView. Added the same 45s-poll + banner pattern, fingerprinting pinned docs and the current tab's docs against fresh fetches; bails silently on a fetch failure instead of false-positive triggering the banner. - Tab load errors showed a message but no way to retry short of switching tabs and back. Added a Retry button matching the collection document list's. Also hardens CODEOWNERS ahead of going public: kept `* @psmattas` as the catch-all but pinned supply-chain/governance/CI paths (Package manifests, .gitea/, xcodeproj build settings, scripts/, LICENSE, CONTRIBUTING/SECURITY/SETUP, CLAUDE.md, docs/ARCHITECTURE.md) explicitly to @psmattas so they stay owner-gated even if `*` opens up to other contributors later.
136 lines
5.0 KiB
Swift
136 lines
5.0 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OutlineKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class HomeViewModel {
|
|
private(set) var pinnedDocuments: [OutlineDocument] = []
|
|
private(set) var recentlyViewed: [OutlineDocument] = []
|
|
private(set) var popular: [OutlineDocument] = []
|
|
private(set) var recentlyUpdated: [OutlineDocument] = []
|
|
private(set) var createdByMe: [OutlineDocument] = []
|
|
|
|
var isLoadingPinned = false
|
|
var isLoadingTab = false
|
|
var errorMessage: String?
|
|
/// Drives a "Refresh" banner rather than silently swapping content out
|
|
/// from under whoever's looking at it — see `CollectionsViewModel`'s
|
|
/// identical pattern.
|
|
var hasRemoteChanges = false
|
|
|
|
private let apiClient: OutlineAPIClient
|
|
private var currentUserID: String?
|
|
|
|
init(apiClient: OutlineAPIClient) {
|
|
self.apiClient = apiClient
|
|
}
|
|
|
|
func documents(for tab: HomeTab) -> [OutlineDocument] {
|
|
switch tab {
|
|
case .recentlyViewed: recentlyViewed
|
|
case .popular: popular
|
|
case .recentlyUpdated: recentlyUpdated
|
|
case .createdByMe: createdByMe
|
|
}
|
|
}
|
|
|
|
func loadPinned() async {
|
|
isLoadingPinned = true
|
|
defer { isLoadingPinned = false }
|
|
pinnedDocuments = await fetchPinned()
|
|
}
|
|
|
|
func load(tab: HomeTab) async {
|
|
isLoadingTab = true
|
|
errorMessage = nil
|
|
defer { isLoadingTab = false }
|
|
do {
|
|
let documents = try await fetch(tab: tab)
|
|
set(documents, for: tab)
|
|
hasRemoteChanges = false
|
|
} catch {
|
|
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load documents.")
|
|
}
|
|
}
|
|
|
|
/// Fetches fresh pinned docs and the current tab's documents to compare
|
|
/// against what's displayed, without replacing either. Bails silently
|
|
/// on a fetch failure rather than treating it as "changed" — a
|
|
/// transient network hiccup shouldn't pop the refresh banner.
|
|
func checkForRemoteChanges(tab: HomeTab) async {
|
|
async let freshPinned = fetchPinned()
|
|
guard let freshTab = try? await fetch(tab: tab) else { return }
|
|
let pinned = await freshPinned
|
|
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|
|
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
|
|
hasRemoteChanges = true
|
|
}
|
|
}
|
|
|
|
/// `pins.list` only returns pin records, not the documents themselves —
|
|
/// fetches each pinned document individually. Pins are a small curated
|
|
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
|
/// where it wouldn't be in the sidebar.
|
|
private func fetchPinned() async -> [OutlineDocument] {
|
|
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else {
|
|
return []
|
|
}
|
|
var documents: [OutlineDocument] = []
|
|
for pin in pins {
|
|
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
|
documents.append(document)
|
|
}
|
|
}
|
|
return documents
|
|
}
|
|
|
|
private func fetch(tab: HomeTab) async throws -> [OutlineDocument] {
|
|
switch tab {
|
|
case .recentlyViewed:
|
|
return try await apiClient.listViewedDocuments(offset: 0, limit: 25)
|
|
case .popular:
|
|
// `sort: "viewCount"` was a guess and the server rejected it
|
|
// outright ("sort: Invalid input") — sort is validated
|
|
// server-side against a fixed set, not free-form like the
|
|
// vendored spec's typing implies. Same conclusion as
|
|
// `CollectionTab.popular`: there's no real popularity
|
|
// ranking exposed via the REST API, so this falls back to
|
|
// the default list order rather than guessing again.
|
|
return try await apiClient.documentsList(DocumentsListRequest(limit: 25))
|
|
case .recentlyUpdated:
|
|
return try await apiClient.documentsList(
|
|
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
|
|
)
|
|
case .createdByMe:
|
|
let userId = try await resolveCurrentUserID()
|
|
return try await apiClient.documentsList(
|
|
DocumentsListRequest(userId: userId, sort: "createdAt", direction: "DESC", limit: 25)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func set(_ documents: [OutlineDocument], for tab: HomeTab) {
|
|
switch tab {
|
|
case .recentlyViewed: recentlyViewed = documents
|
|
case .popular: popular = documents
|
|
case .recentlyUpdated: recentlyUpdated = documents
|
|
case .createdByMe: createdByMe = documents
|
|
}
|
|
}
|
|
|
|
private func resolveCurrentUserID() async throws -> String {
|
|
if let currentUserID { return currentUserID }
|
|
let user = try await apiClient.currentUser()
|
|
currentUserID = user.id
|
|
return user.id
|
|
}
|
|
|
|
private static func fingerprint(_ documents: [OutlineDocument]) -> String {
|
|
documents
|
|
.map { "\($0.id):\($0.updatedAt.timeIntervalSince1970)" }
|
|
.sorted()
|
|
.joined(separator: "|")
|
|
}
|
|
}
|