Files
Outpost/Outpost/Features/Home/HomeViewModel.swift
T
Puranjay Savar Mattas 8ce0804c67 fix(home): remove dead search icon, add staleness polling and retry
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.
2026-08-14 17:19:07 +01:00

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: "|")
}
}