From b9817218bb6ac872161c4162ddca0f606e5b7cb2 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 22 Jun 2026 08:44:03 -0700 Subject: [PATCH 01/16] Latest work for the interface match. --- App/Composition/AppEnvironment.swift | 132 +++++++++ App/Features/Lists/ListDetailView.swift | 227 +++++++++++++++ App/Features/Lists/ListDetailViewModel.swift | 161 ++++++++++ App/Features/Lists/ListRowSummaryView.swift | 102 +++++++ App/Features/Lists/ListsBrowserView.swift | 241 +++++++++++++++ .../Lists/ListsBrowserViewModel.swift | 151 ++++++++++ App/Features/Lists/ListsPlaceholderView.swift | 6 +- App/Features/Social/ProfileHeaderView.swift | 174 +++++++++++ App/Features/Social/ProfileRootView.swift | 217 ++++++++++++++ App/Features/Social/ProfileViewModel.swift | 138 +++++++++ .../Social/SocialPlaceholderView.swift | 6 +- App/Features/Timeline/MessageDetailView.swift | 110 +++++++ .../Timeline/MessageDetailViewModel.swift | 76 +++++ App/Features/Timeline/MessageRowView.swift | 173 +++++++++++ .../Timeline/TimelinePlaceholderView.swift | 6 +- App/Features/Timeline/TimelineRootView.swift | 219 ++++++++++++++ App/Features/Timeline/TimelineViewModel.swift | 164 +++++++++++ App/InterlinedListApp.swift | 20 +- App/Navigation/MainWindowView.swift | 60 ++-- .../InterlinedDomain/Models/ListDetail.swift | 40 +++ .../InterlinedDomain/Models/ListMappers.swift | 103 +++++++ .../InterlinedDomain/Models/ListRow.swift | 85 ++++++ .../InterlinedDomain/Models/ListSummary.swift | 62 ++++ .../Models/ProfileMappers.swift | 114 ++++++++ .../InterlinedDomain/Models/UserProfile.swift | 74 +++++ .../Services/ListsService.swift | 113 ++++++++ .../Services/SocialService.swift | 177 +++++++++++ .../ListsServiceTests.swift | 238 +++++++++++++++ .../SocialServiceTests.swift | 274 ++++++++++++++++++ .../Support/Fixtures.swift | 148 ++++++++++ .../InterlinedPersistence.swift | 27 +- .../Mapping/MessageRecordMapping.swift | 106 +++++++ .../Schema/MessageRecord.swift | 93 ++++++ .../Schema/TimelinePageRecord.swift | 35 +++ .../Stores/SwiftDataMessageStore.swift | 229 +++++++++++++++ .../InterlinedPersistenceTests.swift | 15 +- .../SwiftDataMessageStoreTests.swift | 270 +++++++++++++++++ docs/api-coverage.md | 206 ++++++------- .../decisions/0002-public-profile-fallback.md | 125 ++++++++ docs/progress.md | 167 +++++++++++ 40 files changed, 4946 insertions(+), 138 deletions(-) create mode 100644 App/Composition/AppEnvironment.swift create mode 100644 App/Features/Lists/ListDetailView.swift create mode 100644 App/Features/Lists/ListDetailViewModel.swift create mode 100644 App/Features/Lists/ListRowSummaryView.swift create mode 100644 App/Features/Lists/ListsBrowserView.swift create mode 100644 App/Features/Lists/ListsBrowserViewModel.swift create mode 100644 App/Features/Social/ProfileHeaderView.swift create mode 100644 App/Features/Social/ProfileRootView.swift create mode 100644 App/Features/Social/ProfileViewModel.swift create mode 100644 App/Features/Timeline/MessageDetailView.swift create mode 100644 App/Features/Timeline/MessageDetailViewModel.swift create mode 100644 App/Features/Timeline/MessageRowView.swift create mode 100644 App/Features/Timeline/TimelineRootView.swift create mode 100644 App/Features/Timeline/TimelineViewModel.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListDetail.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListRow.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListSummary.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListsServiceTests.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift create mode 100644 Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift create mode 100644 Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/MessageRecord.swift create mode 100644 Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/TimelinePageRecord.swift create mode 100644 Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataMessageStore.swift create mode 100644 Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataMessageStoreTests.swift create mode 100644 docs/decisions/0002-public-profile-fallback.md diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift new file mode 100644 index 0000000..4ee746b --- /dev/null +++ b/App/Composition/AppEnvironment.swift @@ -0,0 +1,132 @@ +// AppEnvironment +// +// Composition root for the App target (PLAN.md §3 — the App target is +// UI-only and depends on domain protocols). Constructs the concrete +// graph of services once at launch and exposes only the protocol-typed +// surfaces the view layer binds against. +// +// View models never construct services themselves — they read this +// environment via the `appEnvironment` value or as an `EnvironmentObject` +// and depend on the domain protocols (`MessagesServicing`, …) so they +// remain trivially substitutable in tests. + +import Foundation +import SwiftUI +import InterlinedDomain +import InterlinedKit +import InterlinedPersistence + +/// Top-level service container constructed in `InterlinedListApp` at +/// launch. Pure DI: the type owns nothing reactive itself; it just +/// publishes already-wired services for views to consume. +@MainActor +final class AppEnvironment: ObservableObject { + + /// The timeline + message read service the Timeline feature binds + /// against. Exposed as the protocol so test doubles substitute in. + let messages: MessagesServicing + + /// The public-list browse service the Lists feature binds against + /// (PLAN.md §1 "Public list browsing", §6 M1). Exposed as the + /// protocol so test doubles substitute in. + let lists: ListsServicing + + /// The read-only social surface the Social feature binds against for + /// the M1 profile UI (PLAN.md §1 "Profile" / "Follow system", §6 M1). + /// Exposed as the protocol so test doubles substitute in. Profile reads + /// are the public-author fallback per decision 0002; follower / following + /// counts are populated via `counts(of:)` once a userId is in hand. + let social: SocialServicing + + /// Designated initializer used by tests and previews that want to + /// inject a fully synthetic service graph. Production code calls + /// `live()` instead. + init( + messages: MessagesServicing, + lists: ListsServicing, + social: SocialServicing + ) { + self.messages = messages + self.lists = lists + self.social = social + } + + /// Builds the production service graph: + /// + /// `KeychainTokenStore` → `DefaultAuthTransport` (Bearer-only for + /// M1; the session establisher is `NullSessionEstablisher` because + /// the timeline feature only touches Bearer endpoints) → + /// `APIClient` → `SwiftDataMessageStore` → `MessagesService`. + /// + /// TODO: M4 — swap the in-memory message store for a persistent + /// one once `InterlinedPersistence` exposes a public factory for + /// the on-disk `ModelContainer`. The schema types are package- + /// internal today, so the App target cannot construct a persistent + /// container without crossing the package boundary. Per PLAN.md §5 + /// the cache accelerates rendering but is best-effort; the + /// in-memory variant keeps stale-while-revalidate working within + /// a single session, and document sync (M4) is what actually + /// needs persistence. + static func live() -> AppEnvironment { + let tokenStore = KeychainTokenStore() + let authTransport = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: URLSession.shared, + sessionEstablisher: NullSessionEstablisher() + ) + let api = APIClient(authTransport: authTransport) + let store = Self.makeMessageStore() + let messages = MessagesService(api: api, store: store) + // Reuse the same kit-layer APIClient: M1 list browsing hits the + // same Bearer-only public endpoints the timeline does, so a + // second client would be redundant and would double up auth + // bookkeeping at no benefit. + let lists = ListsService(api: api) + // Same `APIClient` reuse as `lists` — the M1 profile read hits the + // same Bearer-or-public endpoints (`/api/user/[username]/messages` + // via the decision 0002 fallback, plus `/api/follow/[id]/counts`). + let social = SocialService(api: api) + return AppEnvironment(messages: messages, lists: lists, social: social) + } + + // MARK: - Store construction + + /// Returns an in-memory `SwiftDataMessageStore`, falling back to a + /// no-op cache if even that cannot be constructed (sandbox edge + /// cases). Persistence is a stale-while-revalidate accelerator + /// (PLAN.md §5) — losing it is not fatal. + private static func makeMessageStore() -> MessageStore { + if let inMemory = try? SwiftDataMessageStore.inMemory() { + return inMemory + } + return NullMessageStore() + } +} + +// MARK: - NullMessageStore + +/// No-op cache used only when the in-memory SwiftData store cannot be +/// constructed at all. The service contract treats every cache as +/// best-effort so a no-op is a safe last-resort fallback. +private struct NullMessageStore: MessageStore { + func cachedTimeline(scope: TimelineScope, tag: String?) async -> [Message] { [] } + func replaceTimeline(_ messages: [Message], scope: TimelineScope, tag: String?) async {} + func cachedMessage(id: String) async -> Message? { nil } + func upsert(_ messages: [Message]) async {} + func clear() async {} +} + +// MARK: - Environment plumbing + +private struct AppEnvironmentKey: EnvironmentKey { + static let defaultValue: AppEnvironment? = nil +} + +extension EnvironmentValues { + /// The shared service container. `nil` outside a wired-up scene — + /// every production scene must inject one via `.environment(...)`. + var appEnvironment: AppEnvironment? { + get { self[AppEnvironmentKey.self] } + set { self[AppEnvironmentKey.self] = newValue } + } +} diff --git a/App/Features/Lists/ListDetailView.swift b/App/Features/Lists/ListDetailView.swift new file mode 100644 index 0000000..b22d5d5 --- /dev/null +++ b/App/Features/Lists/ListDetailView.swift @@ -0,0 +1,227 @@ +// ListDetailView +// +// Detail screen for a single public list. Loads metadata + the first +// page of rows and renders them as a `List` of key/value cards, one +// card per row (PLAN.md §1 "Structured lists", §6 M1). +// +// Why a card list and not a `Table` in M1: +// +// - `ListRow.fields` is loose-typed (`[String: ListCellValue]`) until +// the M3 schema-DSL parser lands, so columns are only derivable +// from observed row data — not from a static schema. +// - SwiftUI `Table` requires either statically-keyed `TableColumn`s +// or `TableColumnForEach`, and the latter is macOS 14.4+. The +// project targets macOS 14.0, so the dynamic-column path is not +// yet available to us. +// +// The typed-per-column schema editor and full Table-with-typed-columns +// land in M3 alongside the schema DSL parser. Here we render what the +// loose `ListCellValue` projection exposes — `displayText` for every +// cell — so the read-only browser ships without the parser. + +import SwiftUI +import InterlinedDomain + +struct ListDetailView: View { + + let username: String + let slug: String + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: ListDetailViewModel? + + var body: some View { + Group { + if let viewModel { + detailBody(viewModel: viewModel) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle(viewModel?.detail?.title ?? "List") + .task { + if viewModel == nil, let environment { + let model = ListDetailViewModel( + lists: environment.lists, + username: username, + slug: slug + ) + viewModel = model + await model.load() + } + } + } + + // MARK: - Body sections + + @ViewBuilder + private func detailBody(viewModel: ListDetailViewModel) -> some View { + VStack(spacing: 0) { + header(viewModel: viewModel) + Divider() + rowsContent(viewModel: viewModel) + } + .refreshable { + await viewModel.refresh() + } + } + + @ViewBuilder + private func header(viewModel: ListDetailViewModel) -> some View { + if let detail = viewModel.detail { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "list.bullet.rectangle") + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + Text(detail.title) + .font(.title2.weight(.semibold)) + if detail.visibility == .private { + Label("Private", systemImage: "lock") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("Private list") + } + Spacer() + Text("@\(username)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + if let description = detail.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let schema = detail.schemaDescription, !schema.isEmpty { + Label(schema, systemImage: "tablecells") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("Schema: \(schema)") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } else { + EmptyView() + } + } + + @ViewBuilder + private func rowsContent(viewModel: ListDetailViewModel) -> some View { + if let error = viewModel.error, viewModel.rows.isEmpty { + errorState(error: error, viewModel: viewModel) + } else if viewModel.rows.isEmpty, viewModel.isLoading { + loadingState + } else if viewModel.rows.isEmpty { + emptyRowsState + } else { + rowsCardList(viewModel: viewModel, columns: viewModel.columns) + } + } + + // MARK: - Rendering modes + + @ViewBuilder + private func rowsCardList(viewModel: ListDetailViewModel, columns: [String]) -> some View { + List { + ForEach(viewModel.rows) { row in + rowCard(row: row, columns: columns) + .onAppear { + if shouldLoadMore(for: row, in: viewModel.rows) { + Task { await viewModel.loadMore() } + } + } + } + if viewModel.isLoading, !viewModel.rows.isEmpty { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .padding(.vertical, 8) + } + } + .listStyle(.inset) + } + + @ViewBuilder + private func rowCard(row: ListRow, columns: [String]) -> some View { + // When `columns` was empty (no rows on screen yet at compute + // time) fall back to the row's own field keys, sorted for a + // deterministic render. + let keys = columns.isEmpty ? row.fields.keys.sorted() : columns + VStack(alignment: .leading, spacing: 4) { + ForEach(keys, id: \.self) { key in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(key) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(minWidth: 80, alignment: .leading) + Text(row.fields[key]?.displayText ?? "") + .font(.body) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } + + // MARK: - States + + private var loadingState: some View { + VStack(spacing: 8) { + ProgressView() + Text("Loading rows…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyRowsState: some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No rows") + .font(.headline) + Text("This list has no rows yet.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func errorState(error: Error, viewModel: ListDetailViewModel) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + Text("Couldn't load this list") + .font(.headline) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Try again") { + Task { await viewModel.refresh() } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Helpers + + private func shouldLoadMore(for row: ListRow, in loaded: [ListRow]) -> Bool { + guard let index = loaded.firstIndex(where: { $0.id == row.id }) else { return false } + return index >= max(0, loaded.count - 5) + } +} diff --git a/App/Features/Lists/ListDetailViewModel.swift b/App/Features/Lists/ListDetailViewModel.swift new file mode 100644 index 0000000..9b603b7 --- /dev/null +++ b/App/Features/Lists/ListDetailViewModel.swift @@ -0,0 +1,161 @@ +// ListDetailViewModel +// +// Drives `ListDetailView`: loads a single public list's metadata plus +// paged rows. Reads through `ListsServicing` only — no direct API or +// cache access — so unit tests substitute a stub service (PLAN.md §3, +// §7). +// +// M1 keeps the row shape loose (`[String: ListCellValue]`). The +// typed-per-column schema editor and full Table-with-typed-columns +// land in M3 alongside the schema DSL parser (PLAN.md §6 M3, §7 +// "Schema DSL parser"). + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ListDetailViewModel { + + // MARK: - Configuration + + /// Page size for both the initial row load and infinite scroll. + /// Larger than the timeline default because rows are denser than + /// messages and a typical list fits a few dozen rows per fetch. + static let pageSize: Int = 50 + + private let lists: ListsServicing + private let username: String + private let slug: String + + // MARK: - Observable state + + /// The list's metadata (title, description, schema string). `nil` + /// before the first successful load. + private(set) var detail: ListDetail? + + /// Rows loaded so far, in display order. + private(set) var rows: [ListRow] = [] + + /// True while a network round-trip is in flight (initial load, + /// refresh, or load-more). + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed load. Cleared on the + /// next successful round-trip. + private(set) var error: Error? + + /// Whether the server reports more pages beyond what's loaded. + private(set) var hasMore: Bool = false + + /// The `offset` to pass on the next `loadMore` call. `nil` when + /// `hasMore` is false. + private(set) var nextOffset: Int? + + // MARK: - Init + + init(lists: ListsServicing, username: String, slug: String) { + self.lists = lists + self.username = username + self.slug = slug + } + + // MARK: - Intents + + /// Loads the list metadata and the first page of rows in parallel. + /// Either failure surfaces as `error` and the other half is still + /// populated when it succeeds — partial results beat an empty + /// detail screen. + func load() async { + isLoading = true + error = nil + defer { isLoading = false } + + async let detailResult = loadDetail() + async let rowsResult = loadFirstRowsPage() + + let (loadedDetail, loadedPage) = await (detailResult, rowsResult) + + if let loadedDetail { self.detail = loadedDetail } + if let loadedPage { + self.rows = loadedPage.rows + self.hasMore = loadedPage.hasMore + self.nextOffset = loadedPage.nextOffset + } + } + + /// Re-fetches both halves; bound to the detail view's + /// `.refreshable` modifier. + func refresh() async { + await load() + } + + /// Appends the next page of rows when one exists. No-op while a + /// load is in flight or when `hasMore` is false. + func loadMore() async { + guard !isLoading, hasMore, let offset = nextOffset else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await lists.publicRows( + username: username, + slug: slug, + limit: Self.pageSize, + offset: offset + ) + rows.append(contentsOf: page.rows) + hasMore = page.hasMore + nextOffset = page.nextOffset + error = nil + } catch { + self.error = error + } + } + + // MARK: - Derived + + /// Stable column order derived from the loaded rows' field keys. + /// We sort alphabetically so the rendering is deterministic across + /// renders; the schema-defined column order lands in M3 with the + /// DSL parser. + var columns: [String] { + guard !rows.isEmpty else { return [] } + var seen = Set() + var ordered: [String] = [] + for row in rows { + for key in row.fields.keys where !seen.contains(key) { + seen.insert(key) + ordered.append(key) + } + } + return ordered.sorted() + } + + // MARK: - Internals + + private func loadDetail() async -> ListDetail? { + do { + return try await lists.publicList(username: username, slug: slug) + } catch { + self.error = error + return nil + } + } + + private func loadFirstRowsPage() async -> RowsPage? { + do { + return try await lists.publicRows( + username: username, + slug: slug, + limit: Self.pageSize, + offset: 0 + ) + } catch { + // Don't clobber a detail-load error with a rows-load error + // — the first failure is the more actionable one. + if self.error == nil { self.error = error } + return nil + } + } +} diff --git a/App/Features/Lists/ListRowSummaryView.swift b/App/Features/Lists/ListRowSummaryView.swift new file mode 100644 index 0000000..664127d --- /dev/null +++ b/App/Features/Lists/ListRowSummaryView.swift @@ -0,0 +1,102 @@ +// ListRowSummaryView +// +// Single browser-row summary card for a `ListSummary`: title, +// description, visibility badge, and a relative-time "updated" stamp. +// Uses SF Symbols and the brand `AccentColor` from the asset catalog +// (PLAN.md §9). Every interactive affordance carries a VoiceOver +// label; sizes honour Dynamic Type by leaning on `.font(.body)` / +// `.font(.subheadline)`. + +import SwiftUI +import InterlinedDomain + +struct ListRowSummaryView: View { + let summary: ListSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + header + if let description = summary.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + footer + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilitySummary) + } + + // MARK: - Sections + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "list.bullet.rectangle") + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + Text(summary.title) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 8) + if summary.visibility == .private { + visibilityBadge + } + } + } + + private var visibilityBadge: some View { + Label("Private", systemImage: "lock") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("Private list") + } + + private var footer: some View { + HStack(spacing: 12) { + if let updatedAt = summary.updatedAt { + Label( + Self.relativeFormatter.localizedString(for: updatedAt, relativeTo: .now), + systemImage: "clock" + ) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("Updated \(Self.fullFormatter.string(from: updatedAt))") + } + Spacer() + } + } + + // MARK: - Helpers + + private var accessibilitySummary: String { + var parts: [String] = [] + parts.append(summary.title) + if let description = summary.description, !description.isEmpty { + parts.append(description) + } + if summary.visibility == .private { + parts.append("Private list") + } + if let updatedAt = summary.updatedAt { + parts.append("Updated \(Self.fullFormatter.string(from: updatedAt))") + } + return parts.joined(separator: ". ") + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + private static let fullFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} diff --git a/App/Features/Lists/ListsBrowserView.swift b/App/Features/Lists/ListsBrowserView.swift new file mode 100644 index 0000000..d463ed8 --- /dev/null +++ b/App/Features/Lists/ListsBrowserView.swift @@ -0,0 +1,241 @@ +// ListsBrowserView +// +// Public-list browser (PLAN.md §1 "Public list browsing", §6 M1). +// The user enters a username and we render that user's public lists +// in a `NavigationStack`; tapping a row pushes `ListDetailView` onto +// the detail column's own stack. +// +// The view is a thin shell over `ListsBrowserViewModel`: it observes +// state, dispatches user intents, and leaves all loading / paging +// logic in the view model so unit tests cover the behavior without +// touching SwiftUI. + +import SwiftUI +import InterlinedDomain + +struct ListsBrowserView: View { + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: ListsBrowserViewModel? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + browserBody(viewModel: viewModel) + } else { + unconfiguredState + } + } + .navigationTitle("Lists") + .navigationDestination(for: ListDetailRoute.self) { route in + ListDetailView(username: route.username, slug: route.slug) + } + } + .task { + // Defer construction until the environment is in scope. + // SwiftUI doesn't expose `@Environment` during `init`, so + // building the view model in `.task` is the canonical + // pattern. + if viewModel == nil, let environment { + viewModel = ListsBrowserViewModel(lists: environment.lists) + } + } + } + + // MARK: - Body sections + + @ViewBuilder + private func browserBody(viewModel: ListsBrowserViewModel) -> some View { + VStack(spacing: 0) { + toolbar(viewModel: viewModel) + Divider() + content(viewModel: viewModel) + } + .refreshable { + await viewModel.refresh() + } + } + + @ViewBuilder + private func toolbar(viewModel: ListsBrowserViewModel) -> some View { + HStack(spacing: 8) { + Image(systemName: "at") + .foregroundStyle(.secondary) + TextField( + "Browse a user's lists", + text: Binding( + get: { viewModel.usernameInput }, + set: { viewModel.usernameInput = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await viewModel.loadInitial(username: viewModel.usernameInput) } + } + .accessibilityLabel("Username to browse") + + Button("Browse") { + Task { await viewModel.loadInitial(username: viewModel.usernameInput) } + } + .keyboardShortcut(.defaultAction) + .disabled(viewModel.usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if viewModel.loadedUsername != nil { + Button { + viewModel.clear() + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Clear results") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private func content(viewModel: ListsBrowserViewModel) -> some View { + if viewModel.loadedUsername == nil, viewModel.error == nil { + promptState + } else if let error = viewModel.error, viewModel.lists_loaded.isEmpty { + errorState(error: error, viewModel: viewModel) + } else if viewModel.lists_loaded.isEmpty, viewModel.isLoading { + loadingState + } else if viewModel.lists_loaded.isEmpty { + emptyState(username: viewModel.loadedUsername ?? "") + } else { + listSection(viewModel: viewModel) + } + } + + @ViewBuilder + private func listSection(viewModel: ListsBrowserViewModel) -> some View { + List { + ForEach(viewModel.lists_loaded) { summary in + NavigationLink( + value: ListDetailRoute( + username: viewModel.loadedUsername ?? "", + slug: summary.id + ) + ) { + ListRowSummaryView(summary: summary) + } + .onAppear { + // Trigger paging when we surface the row five from + // the bottom — keeps scroll smooth; the view model + // gates concurrent loads. + if shouldLoadMore(for: summary, in: viewModel.lists_loaded) { + Task { await viewModel.loadMore() } + } + } + } + if viewModel.isLoading, !viewModel.lists_loaded.isEmpty { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .padding(.vertical, 8) + } + } + .listStyle(.inset) + } + + // MARK: - States + + private var promptState: some View { + VStack(spacing: 8) { + Image(systemName: "list.bullet.rectangle") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + Text("Browse public lists") + .font(.headline) + Text("Enter a username to browse their public lists.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var loadingState: some View { + VStack(spacing: 8) { + ProgressView() + Text("Loading lists…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func emptyState(username: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No public lists") + .font(.headline) + Text("@\(username) has no public lists yet.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func errorState(error: Error, viewModel: ListsBrowserViewModel) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + Text("Couldn't load lists") + .font(.headline) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Try again") { + Task { await viewModel.refresh() } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var unconfiguredState: some View { + // Hit only if the scene wasn't wired through `AppEnvironment`, + // which is a programmer error rather than a runtime one — keep + // the message diagnostic rather than user-facing. + VStack(spacing: 8) { + Image(systemName: "wrench.adjustable") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("Lists unavailable") + .font(.headline) + Text("AppEnvironment is not injected into the view tree.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Helpers + + private func shouldLoadMore(for summary: ListSummary, in loaded: [ListSummary]) -> Bool { + guard let index = loaded.firstIndex(where: { $0.id == summary.id }) else { return false } + return index >= max(0, loaded.count - 5) + } +} + +// MARK: - ListDetailRoute + +/// Value-typed navigation route the browser pushes onto its stack. +/// `Hashable` is required by `.navigationDestination(for:)`. Keeping +/// this scoped to the Lists feature avoids leaking a UI concern into +/// the domain layer. +struct ListDetailRoute: Hashable { + let username: String + let slug: String +} diff --git a/App/Features/Lists/ListsBrowserViewModel.swift b/App/Features/Lists/ListsBrowserViewModel.swift new file mode 100644 index 0000000..d82a0fb --- /dev/null +++ b/App/Features/Lists/ListsBrowserViewModel.swift @@ -0,0 +1,151 @@ +// ListsBrowserViewModel +// +// Drives `ListsBrowserView`: owns the username the user is browsing, +// the loaded summaries, and the loading / paging / error state. Reads +// through `ListsServicing` only — no direct API access — so unit tests +// substitute a stub service (PLAN.md §3, §7). +// +// M1 is read-only and there is no logged-in default; the user types a +// username and we load that user's public lists. The lookup is +// idempotent: calling `loadInitial(username:)` again with the same +// trimmed username is a no-op while a load is in flight. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ListsBrowserViewModel { + + // MARK: - Configuration + + /// Page size for both the initial load and infinite scroll. Mirrors + /// `TimelineViewModel.pageSize` so the App layer's paging is + /// uniform across feature areas. + static let pageSize: Int = 20 + + private let lists: ListsServicing + + // MARK: - Observable state + + /// Text the user is typing into the browse field. Two-way bound + /// from the view; not necessarily the username currently loaded — + /// see `loadedUsername` for that. + var usernameInput: String = "" + + /// The username whose public lists are currently loaded (or being + /// loaded). `nil` before the first successful load. Distinct from + /// `usernameInput` so the view can show "Lists for @alice" even + /// while the user edits the input toward something else. + private(set) var loadedUsername: String? + + /// Lists loaded so far, in display order. + private(set) var lists_loaded: [ListSummary] = [] + + /// True while a network round-trip is in flight (initial or load-more). + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed load. Cleared on the + /// next successful round-trip or when `clear()` is called. + private(set) var error: Error? + + /// Whether the server reports more pages beyond what's loaded. + private(set) var hasMore: Bool = false + + /// The `offset` to pass on the next `loadMore` call. `nil` when + /// `hasMore` is false. + private(set) var nextOffset: Int? + + // MARK: - Init + + init(lists: ListsServicing) { + self.lists = lists + } + + // MARK: - Intents + + /// First-time load for the supplied username. Whitespace-trims the + /// input and bails out on an empty handle so a stray submit doesn't + /// fire a doomed request. Resets paging state on every call. + func loadInitial(username: String) async { + let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + loadedUsername = trimmed + lists_loaded = [] + hasMore = false + nextOffset = nil + error = nil + isLoading = true + defer { isLoading = false } + + do { + let page = try await lists.publicLists( + username: trimmed, + limit: Self.pageSize, + offset: 0 + ) + apply(page, reset: true) + } catch { + self.error = error + } + } + + /// Re-fetches page zero for the currently loaded username. Bound + /// to the browser view's `.refreshable` modifier. No-op when no + /// username is loaded yet. + func refresh() async { + guard let loadedUsername else { return } + await loadInitial(username: loadedUsername) + } + + /// Appends the next page when one exists. No-op while a load is in + /// flight, when `hasMore` is false, or when no username is loaded. + /// The view calls this on the trailing row's `.onAppear` and we + /// de-dupe here rather than push it onto the view. + func loadMore() async { + guard !isLoading, + hasMore, + let offset = nextOffset, + let loadedUsername else { return } + + isLoading = true + defer { isLoading = false } + do { + let page = try await lists.publicLists( + username: loadedUsername, + limit: Self.pageSize, + offset: offset + ) + apply(page, reset: false) + } catch { + self.error = error + } + } + + /// Resets the browser back to the empty prompt state. Used by the + /// view's "clear" affordance and by tests that want a clean slate. + func clear() { + usernameInput = "" + loadedUsername = nil + lists_loaded = [] + hasMore = false + nextOffset = nil + error = nil + isLoading = false + } + + // MARK: - Internals + + private func apply(_ page: ListsPage, reset: Bool) { + if reset { + lists_loaded = page.lists + } else { + lists_loaded.append(contentsOf: page.lists) + } + hasMore = page.hasMore + nextOffset = page.nextOffset + error = nil + } +} diff --git a/App/Features/Lists/ListsPlaceholderView.swift b/App/Features/Lists/ListsPlaceholderView.swift index 37b3769..d1b712e 100644 --- a/App/Features/Lists/ListsPlaceholderView.swift +++ b/App/Features/Lists/ListsPlaceholderView.swift @@ -1,6 +1,10 @@ // ListsPlaceholderView // -// M0 placeholder. Replaced in M3 with the lists feature per PLAN.md §6. +// Superseded by `ListsBrowserView` in M1. Kept here only as a +// minimal fallback view for previews / scaffolding scenarios that do +// not have an `AppEnvironment` available. The main window dispatcher +// (`MainWindowView.SidebarDetailDispatcher`) routes the Lists case +// to `ListsBrowserView()`. import SwiftUI diff --git a/App/Features/Social/ProfileHeaderView.swift b/App/Features/Social/ProfileHeaderView.swift new file mode 100644 index 0000000..44ef0bd --- /dev/null +++ b/App/Features/Social/ProfileHeaderView.swift @@ -0,0 +1,174 @@ +// ProfileHeaderView +// +// Read-only public profile header (PLAN.md §1 "Profile", §6 M1). Pure +// presentation: it takes a `UserProfile` plus an optional `FollowCountsDTO` +// and renders avatar, display name, handle, and the conditional fields. +// +// Per `docs/decisions/0002-public-profile-fallback.md`, the M1 fallback +// populates only `{ id, username, displayName, avatarURL }` on the +// `UserProfile`. Bio, joinedAt, and follower / following counts are nil +// or zero. This view renders *nothing* (not "0", not "—" in fields, not +// "Joined") when those values are absent — surfacing zero placeholders +// would be a UX bug per the decision. +// +// `isPrivate` on `UserProfile` is a non-optional `Bool` (defaults to +// `false`), so we treat the M1 fallback's `false` as "don't show the +// private badge" — the badge is only ever rendered when the value +// transitions to `true`, which won't happen until the upstream profile +// endpoint lands and the decision is revived. + +import SwiftUI +import InterlinedDomain +import InterlinedKit + +struct ProfileHeaderView: View { + + let profile: UserProfile + /// Optional follow-counts follow-up. `nil` while the call is in + /// flight or after it failed (the failure is soft — see + /// `ProfileViewModel.loadProfile`). + let counts: FollowCountsDTO? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 16) { + avatar + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(profile.displayName) + .font(.title2) + .fontWeight(.semibold) + if profile.isPrivate { + // Won't render in M1 (the fallback never sets + // this to true). Wired now so the view is + // ready when the upstream endpoint lands and + // decision 0002 is revived. + Image(systemName: "lock.fill") + .foregroundStyle(.secondary) + .accessibilityLabel("Private account") + } + } + Text("@\(profile.username)") + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() + } + + // Bio: only render when non-nil and non-empty. Per decision + // 0002, this branch never fires in M1 — the fallback omits + // bio entirely. Wired for forward-compatibility. + if let bio = profile.bio, !bio.isEmpty { + Text(bio) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + + // Counts: only render when the follow-up call succeeded. + // Suppress when nil so the user doesn't see "0 followers" + // for a profile whose count we never fetched. + if let counts { + countsRow(counts: counts) + } + + // JoinedAt: only render when non-nil. Per decision 0002, this + // branch never fires in M1. Wired for forward-compatibility. + if let joinedAt = profile.joinedAt { + Label( + "Joined \(joinedAt.formatted(date: .abbreviated, time: .omitted))", + systemImage: "calendar" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel("Profile for \(profile.displayName), @\(profile.username)") + } + + // MARK: - Subviews + + @ViewBuilder + private var avatar: some View { + // `AsyncImage` covers the broken / missing URL case with the + // `placeholder` closure — anything other than a successful image + // load (in-flight, transport error, decode error, nil URL handled + // by the outer `if let`) renders the SF Symbol fallback. The + // visible behavior for a broken avatar URL is identical to the + // "no avatar URL set" state, which is the right call here: + // surfacing a load failure to the user gives them no actionable + // information about a public profile they're browsing. + Group { + if let url = profile.avatarURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .empty, .failure: + avatarFallback + @unknown default: + avatarFallback + } + } + } else { + avatarFallback + } + } + .frame(width: 72, height: 72) + .clipShape(Circle()) + .overlay( + Circle().strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) + ) + .accessibilityHidden(true) + } + + private var avatarFallback: some View { + ZStack { + Color.accentColor.opacity(0.15) + Image(systemName: "person.crop.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(Color.accentColor) + .padding(8) + } + } + + @ViewBuilder + private func countsRow(counts: FollowCountsDTO) -> some View { + HStack(spacing: 24) { + countPill( + value: counts.followerCount, + singular: "follower", + plural: "followers" + ) + countPill( + value: counts.followingCount, + singular: "following", + plural: "following" + ) + } + .font(.callout) + } + + @ViewBuilder + private func countPill(value: Int, singular: String, plural: String) -> some View { + // The counts call yields a concrete `Int`, not a nil placeholder, + // so rendering "0 followers" here is real data — the user has + // zero followers, not an unknown follower count. That's a meaningful + // distinction the API can express; we honor it. + let label = value == 1 ? singular : plural + HStack(spacing: 4) { + Text("\(value)") + .fontWeight(.semibold) + Text(label) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(value) \(label)") + } +} diff --git a/App/Features/Social/ProfileRootView.swift b/App/Features/Social/ProfileRootView.swift new file mode 100644 index 0000000..1b4529e --- /dev/null +++ b/App/Features/Social/ProfileRootView.swift @@ -0,0 +1,217 @@ +// ProfileRootView +// +// Read-only public profile surface (PLAN.md §1 "Profile", §6 M1). The +// user enters a username and we render that user's public profile +// header inside a `NavigationStack`; subsequent profile sub-routes +// (recent messages, followers, following) land in later milestones. +// +// The M1 load is the public-author fallback documented in +// `docs/decisions/0002-public-profile-fallback.md`: identity is +// projected from the embedded author on the user's most recent public +// message, and `SocialError.profileUnavailable(username:)` is the +// typed "no public messages, so no profile to synthesize" outcome — +// rendered here as a friendly empty state, not as a generic error. +// +// The view is a thin shell over `ProfileViewModel`: it observes state, +// dispatches user intents, and leaves all loading / error logic in the +// view model so unit tests cover the behavior without touching SwiftUI. + +import SwiftUI +import InterlinedDomain +import InterlinedKit + +struct ProfileRootView: View { + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: ProfileViewModel? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + profileBody(viewModel: viewModel) + } else { + unconfiguredState + } + } + .navigationTitle("Profile") + } + .task { + // Defer construction until the environment is in scope. + // SwiftUI doesn't expose `@Environment` during `init`, so + // building the view model in `.task` is the canonical + // pattern. + if viewModel == nil, let environment { + viewModel = ProfileViewModel(social: environment.social) + } + } + } + + // MARK: - Body sections + + @ViewBuilder + private func profileBody(viewModel: ProfileViewModel) -> some View { + VStack(spacing: 0) { + toolbar(viewModel: viewModel) + Divider() + content(viewModel: viewModel) + } + .refreshable { + await viewModel.refresh() + } + } + + @ViewBuilder + private func toolbar(viewModel: ProfileViewModel) -> some View { + HStack(spacing: 8) { + Image(systemName: "at") + .foregroundStyle(.secondary) + TextField( + "Browse a user's profile", + text: Binding( + get: { viewModel.usernameInput }, + set: { viewModel.usernameInput = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await viewModel.loadProfile(username: viewModel.usernameInput) } + } + .accessibilityLabel("Username to browse") + + Button("Browse") { + Task { await viewModel.loadProfile(username: viewModel.usernameInput) } + } + .keyboardShortcut(.defaultAction) + .disabled(viewModel.usernameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if viewModel.loadedUsername != nil { + Button { + viewModel.clear() + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Clear results") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private func content(viewModel: ProfileViewModel) -> some View { + if viewModel.loadedUsername == nil, viewModel.error == nil { + promptState + } else if let error = viewModel.error, viewModel.profile == nil { + // Decision 0002: distinguish the typed "no public messages" + // outcome from a generic API error so the user sees a + // friendly empty rather than a scary error card. + if case let SocialError.profileUnavailable(username) = error { + profileUnavailableState(username: username) + } else { + errorState(error: error, viewModel: viewModel) + } + } else if viewModel.profile == nil, viewModel.isLoading { + loadingState + } else if let profile = viewModel.profile { + profileSection(profile: profile, counts: viewModel.counts) + } else { + // Defensive: loadedUsername is set, no error, no profile, + // not loading. Should be unreachable under the current view + // model contract, but render the loading state rather than + // a blank pane if state ever drifts. + loadingState + } + } + + @ViewBuilder + private func profileSection(profile: UserProfile, counts: FollowCountsDTO?) -> some View { + ScrollView { + ProfileHeaderView(profile: profile, counts: counts) + } + } + + // MARK: - States + + private var promptState: some View { + VStack(spacing: 8) { + Image(systemName: "person.crop.circle") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + Text("Browse a public profile") + .font(.headline) + Text("Enter a username to view their public profile.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var loadingState: some View { + VStack(spacing: 8) { + ProgressView() + Text("Loading profile…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func profileUnavailableState(username: String) -> some View { + // Decision 0002: the public-author fallback can't synthesize a + // profile for a user with zero public messages. Surface this as + // an explanatory empty state so the user understands why nothing + // renders — and so it doesn't look like a transport failure. + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("Profile unavailable") + .font(.headline) + Text("@\(username) has no public messages yet, so we can't show their profile in this version.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func errorState(error: Error, viewModel: ProfileViewModel) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + Text("Couldn't load profile") + .font(.headline) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Try again") { + Task { await viewModel.refresh() } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var unconfiguredState: some View { + // Hit only if the scene wasn't wired through `AppEnvironment`, + // which is a programmer error rather than a runtime one — keep + // the message diagnostic rather than user-facing. + VStack(spacing: 8) { + Image(systemName: "wrench.adjustable") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("Profile unavailable") + .font(.headline) + Text("AppEnvironment is not injected into the view tree.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/App/Features/Social/ProfileViewModel.swift b/App/Features/Social/ProfileViewModel.swift new file mode 100644 index 0000000..8e27414 --- /dev/null +++ b/App/Features/Social/ProfileViewModel.swift @@ -0,0 +1,138 @@ +// ProfileViewModel +// +// Drives `ProfileRootView`: owns the username the user is browsing, the +// loaded profile, the follow counts follow-up, and the loading / error +// state. Reads through `SocialServicing` only — no direct API access — +// so unit tests substitute a stub service (PLAN.md §3, §7). +// +// M1 is read-only. The profile load is a single round-trip through the +// public-author fallback documented in `docs/decisions/0002-public-profile-fallback.md`: +// `SocialService.profile(username:)` returns identity only (id, username, +// displayName, avatarURL). Bio, joinedAt, isPrivate, follower / following +// counts are absent on that response. Once the profile resolves we fire a +// follow-up `social.counts(of: profile.id)` to backfill the count pair — +// failure there is *soft* (the profile is the load-bearing data and stays +// rendered). +// +// M2: when a `MessagesService.userMessages(username:)` wrapper exists on +// the Domain side, this view model grows a recent-messages tab. Today the +// kit-level builder (`Messages.userMessages(username:limit:offset:)`) +// exists but isn't wrapped by `MessagesService`, and the App layer is not +// permitted to call the kit directly (layering — see PLAN.md §3). + +import Foundation +import Observation +import InterlinedDomain +import InterlinedKit + +@MainActor +@Observable +final class ProfileViewModel { + + // MARK: - Dependencies + + private let social: SocialServicing + + // MARK: - Observable state + + /// Text the user is typing into the browse field. Two-way bound from + /// the view; not necessarily the username currently loaded. + var usernameInput: String = "" + + /// The username whose profile is currently loaded (or being loaded). + /// `nil` before the first successful load. Distinct from + /// `usernameInput` so the view can show "@alice" even while the user + /// edits the input toward something else. + private(set) var loadedUsername: String? + + /// The resolved profile from `SocialService.profile(username:)`. Per + /// decision 0002 only `{ id, username, displayName, avatarURL }` are + /// populated; the view renders the remaining fields conditionally. + private(set) var profile: UserProfile? + + /// The follower / following counts from the follow-up + /// `social.counts(of:)` call. `nil` when the counts request hasn't + /// completed yet or failed. Counts failure is *soft* — `profile` + /// stays set so the header still renders. + private(set) var counts: FollowCountsDTO? + + /// True while either the profile load or the counts follow-up is in + /// flight. The view shows a single progress indicator for both. + private(set) var isLoading: Bool = false + + /// Surfaced error from the profile load. The view distinguishes + /// `SocialError.profileUnavailable` from generic API errors so the + /// user sees a friendly "no public messages yet" message in the + /// former case (decision 0002). + /// + /// A failed counts follow-up does *not* populate this — it's logged + /// and dropped, with the profile staying rendered. + private(set) var error: Error? + + // MARK: - Init + + init(social: SocialServicing) { + self.social = social + } + + // MARK: - Intents + + /// Loads the profile for the supplied username. Whitespace-trims the + /// input and bails out on an empty handle so a stray submit doesn't + /// fire a doomed request. Resets prior state on every call. + /// + /// On success, chains a `counts(of: profile.id)` follow-up so the + /// view can render follower / following totals when available. + /// Counts failure is logged and dropped — the profile header is the + /// load-bearing data and stays rendered. + func loadProfile(username: String) async { + let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + loadedUsername = trimmed + profile = nil + counts = nil + error = nil + isLoading = true + defer { isLoading = false } + + do { + let resolved = try await social.profile(username: trimmed) + profile = resolved + // Counts follow-up: keyed by userId, which we now have. A + // failure here is non-fatal — we log it and leave `counts` + // nil so the view simply omits the count row. + do { + counts = try await social.counts(of: resolved.id) + } catch { + // Soft error path. Intentionally no rethrow, no + // assignment to `self.error` (the profile must keep + // rendering). Future work: pipe through a real logger. + #if DEBUG + print("ProfileViewModel: counts follow-up failed for \(resolved.id): \(error)") + #endif + } + } catch { + self.error = error + } + } + + /// Re-runs `loadProfile` for the currently loaded username. Bound to + /// the "Try again" button on the error state. No-op when no username + /// is loaded yet. + func refresh() async { + guard let loadedUsername else { return } + await loadProfile(username: loadedUsername) + } + + /// Resets the browser back to the empty prompt state. Used by the + /// view's "clear" affordance and by tests that want a clean slate. + func clear() { + usernameInput = "" + loadedUsername = nil + profile = nil + counts = nil + error = nil + isLoading = false + } +} diff --git a/App/Features/Social/SocialPlaceholderView.swift b/App/Features/Social/SocialPlaceholderView.swift index 75ca8fa..79f15b1 100644 --- a/App/Features/Social/SocialPlaceholderView.swift +++ b/App/Features/Social/SocialPlaceholderView.swift @@ -1,6 +1,10 @@ // SocialPlaceholderView // -// M0 placeholder. Replaced in M5 with profiles/follow/requests per PLAN.md §6. +// Superseded by `ProfileRootView` in M1. Kept here only as a +// minimal fallback view for previews / scaffolding scenarios that do +// not have an `AppEnvironment` available. The main window dispatcher +// (`MainWindowView.SidebarDetailDispatcher`) routes the Profile case +// to `ProfileRootView()`. import SwiftUI diff --git a/App/Features/Timeline/MessageDetailView.swift b/App/Features/Timeline/MessageDetailView.swift new file mode 100644 index 0000000..fddc244 --- /dev/null +++ b/App/Features/Timeline/MessageDetailView.swift @@ -0,0 +1,110 @@ +// MessageDetailView +// +// Detail screen for a single message. Renders the message at the top +// followed by a flat list of replies (the indented thread tree lands +// in M2 alongside reply composition — PLAN.md §1, §6). +// +// Like the timeline, the view is a thin shell over +// `MessageDetailViewModel`. Service access goes through the injected +// `AppEnvironment`, so previews and tests substitute services without +// touching networking. + +import SwiftUI +import InterlinedDomain + +struct MessageDetailView: View { + + let messageID: Message.ID + + @Environment(\.appEnvironment) private var environment + @State private var viewModel: MessageDetailViewModel? + + var body: some View { + Group { + if let viewModel { + detailBody(viewModel: viewModel) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle("Message") + .task { + if viewModel == nil, let environment { + let model = MessageDetailViewModel(messages: environment.messages, messageID: messageID) + viewModel = model + await model.load() + } + } + } + + @ViewBuilder + private func detailBody(viewModel: MessageDetailViewModel) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let message = viewModel.message { + MessageRowView(message: message) + .padding(.horizontal, 16) + Divider() + } + + repliesSection(viewModel: viewModel) + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .refreshable { + await viewModel.refresh() + } + .overlay { + if viewModel.isLoading, viewModel.message == nil { + ProgressView() + } else if let error = viewModel.error, viewModel.message == nil { + errorState(error: error, viewModel: viewModel) + } + } + } + + @ViewBuilder + private func repliesSection(viewModel: MessageDetailViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Replies") + .font(.headline) + .padding(.horizontal, 16) + + if viewModel.replies.isEmpty, !viewModel.isLoading { + Text("No replies yet.") + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + } else { + ForEach(viewModel.replies) { reply in + MessageRowView(message: reply) + .padding(.horizontal, 16) + Divider() + .padding(.leading, 16) + } + } + } + } + + @ViewBuilder + private func errorState(error: Error, viewModel: MessageDetailViewModel) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + Text("Couldn't load the message") + .font(.headline) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Try again") { + Task { await viewModel.refresh() } + } + .buttonStyle(.borderedProminent) + } + .padding() + } +} diff --git a/App/Features/Timeline/MessageDetailViewModel.swift b/App/Features/Timeline/MessageDetailViewModel.swift new file mode 100644 index 0000000..be921ac --- /dev/null +++ b/App/Features/Timeline/MessageDetailViewModel.swift @@ -0,0 +1,76 @@ +// MessageDetailViewModel +// +// Drives `MessageDetailView`: loads a single message plus its first +// page of replies. Replies are kept flat for M1; the indented thread +// tree lands in M2 alongside reply composition (PLAN.md §1, §6). +// +// Reads through `MessagesServicing` only, so a stub service drives +// tests without any networking. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class MessageDetailViewModel { + + static let pageSize: Int = 50 + + private let messages: MessagesServicing + private let messageID: String + + private(set) var message: Message? + private(set) var replies: [Message] = [] + private(set) var isLoading: Bool = false + private(set) var error: Error? + + init(messages: MessagesServicing, messageID: String) { + self.messages = messages + self.messageID = messageID + } + + /// Loads the message and its first page of replies in parallel. + /// Either failure surfaces as `error` and the other half is still + /// populated when it succeeds — partial results beat an empty + /// detail screen. + func load() async { + isLoading = true + error = nil + defer { isLoading = false } + + async let messageResult = loadMessage() + async let repliesResult = loadReplies() + + let (loadedMessage, loadedReplies) = await (messageResult, repliesResult) + + if let loadedMessage { self.message = loadedMessage } + if let loadedReplies { self.replies = loadedReplies } + } + + /// Re-fetches both halves; bound to the detail view's + /// `.refreshable` modifier. + func refresh() async { + await load() + } + + private func loadMessage() async -> Message? { + do { + return try await messages.message(id: messageID) + } catch { + self.error = error + return nil + } + } + + private func loadReplies() async -> [Message]? { + do { + return try await messages.replies(of: messageID, limit: Self.pageSize, offset: 0) + } catch { + // Don't clobber a message-load error with a replies-load + // error — the first failure is the more actionable one. + if self.error == nil { self.error = error } + return nil + } + } +} diff --git a/App/Features/Timeline/MessageRowView.swift b/App/Features/Timeline/MessageRowView.swift new file mode 100644 index 0000000..570416f --- /dev/null +++ b/App/Features/Timeline/MessageRowView.swift @@ -0,0 +1,173 @@ +// MessageRowView +// +// Single timeline row: author identity, relative timestamp, body +// (plain text for M1 — Markdown rendering lands in M2), tag chips, +// dig count, and a repost indicator. Uses SF Symbols and the brand +// `AccentColor` from the asset catalog (PLAN.md §9). Every +// interactive affordance carries a VoiceOver label; sizes honour +// Dynamic Type by leaning on `.font(.body)` / `.font(.subheadline)`. + +import SwiftUI +import InterlinedDomain + +struct MessageRowView: View { + let message: Message + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + if let repost = message.repost { + repostBanner(original: repost.original) + } + bodyText + if !message.tags.isEmpty { + tagChips + } + footer + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilitySummary) + } + + // MARK: - Sections + + private var header: some View { + HStack(alignment: .center, spacing: 8) { + avatar + VStack(alignment: .leading, spacing: 2) { + Text(message.author.displayName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text("@\(message.author.username)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Text(Self.relativeFormatter.localizedString(for: message.createdAt, relativeTo: .now)) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("Posted \(Self.fullFormatter.string(from: message.createdAt))") + } + } + + private var avatar: some View { + AsyncImage(url: message.author.avatarURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + default: + Image(systemName: "person.crop.circle.fill") + .resizable() + .foregroundStyle(.secondary) + } + } + .frame(width: 32, height: 32) + .clipShape(Circle()) + .accessibilityHidden(true) + } + + private var bodyText: some View { + // Plain text for M1; Markdown source rendering lands in M2 + // (PLAN.md §6). `.body` honours Dynamic Type. + Text(message.text) + .font(.body) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + private var tagChips: some View { + // A wrapping run of small badges. `FlowLayout` is iOS 17+ only, + // so we use a horizontal stack with wrapping by way of `Lazy` + // grids would over-engineer it — for M1 the row count is small + // and a single horizontal stack is fine. + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(message.tags, id: \.self) { tag in + Text("#\(tag)") + .font(.caption2.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.accentColor.opacity(0.15), in: Capsule()) + .foregroundStyle(Color.accentColor) + .accessibilityLabel("Tag \(tag)") + } + } + } + } + + private var footer: some View { + HStack(spacing: 16) { + Label("\(message.digCount)", systemImage: message.didDig ? "hand.thumbsup.fill" : "hand.thumbsup") + .font(.caption) + .foregroundStyle(message.didDig ? Color.accentColor : .secondary) + .accessibilityLabel("\(message.digCount) digs\(message.didDig ? ", you dug this" : "")") + + if message.repostCount > 0 { + Label("\(message.repostCount)", systemImage: "arrow.2.squarepath") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("\(message.repostCount) reposts") + } + + if let count = message.replyCount, count > 0 { + Label("\(count)", systemImage: "bubble.left") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("\(count) replies") + } + + if message.visibility == .private { + Label("Private", systemImage: "lock") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("Private post") + } + + Spacer() + } + } + + private func repostBanner(original: Message) -> some View { + HStack(spacing: 6) { + Image(systemName: "arrow.2.squarepath") + .font(.caption) + Text("Reposted from @\(original.author.username)") + .font(.caption) + } + .foregroundStyle(.secondary) + .accessibilityLabel("Reposted from @\(original.author.username)") + } + + // MARK: - Helpers + + private var accessibilitySummary: String { + var parts: [String] = [] + parts.append("\(message.author.displayName), @\(message.author.username)") + parts.append(Self.fullFormatter.string(from: message.createdAt)) + parts.append(message.text) + if !message.tags.isEmpty { + parts.append("Tags: \(message.tags.joined(separator: ", "))") + } + parts.append("\(message.digCount) digs") + return parts.joined(separator: ". ") + } + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + private static let fullFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} diff --git a/App/Features/Timeline/TimelinePlaceholderView.swift b/App/Features/Timeline/TimelinePlaceholderView.swift index 5df2dd6..78381ec 100644 --- a/App/Features/Timeline/TimelinePlaceholderView.swift +++ b/App/Features/Timeline/TimelinePlaceholderView.swift @@ -1,6 +1,10 @@ // TimelinePlaceholderView // -// M0 placeholder. Replaced in M1 with the real feed view per PLAN.md §6. +// Superseded by `TimelineRootView` in M1. Kept here only as a +// minimal fallback view for previews / scaffolding scenarios that do +// not have an `AppEnvironment` available. The main window dispatcher +// (`MainWindowView.SidebarDetailDispatcher`) routes the Timeline +// case to `TimelineRootView()`. import SwiftUI diff --git a/App/Features/Timeline/TimelineRootView.swift b/App/Features/Timeline/TimelineRootView.swift new file mode 100644 index 0000000..5068779 --- /dev/null +++ b/App/Features/Timeline/TimelineRootView.swift @@ -0,0 +1,219 @@ +// TimelineRootView +// +// Read-only timeline feed (PLAN.md §1 / §6 M1). Owns the scope picker +// and tag filter affordances and renders the message list inside a +// `NavigationStack` so a tap pushes `MessageDetailView` onto the +// detail column's own stack. +// +// The view is a thin shell over `TimelineViewModel`: it observes +// state, dispatches user intents, and leaves all loading / paging +// logic in the view model so unit tests cover the behavior without +// touching SwiftUI. + +import SwiftUI +import InterlinedDomain + +struct TimelineRootView: View { + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: TimelineViewModel? + @State private var selection: Message.ID? + @State private var tagDraft: String = "" + + var body: some View { + NavigationStack { + Group { + if let viewModel { + timelineBody(viewModel: viewModel) + } else { + unconfiguredState + } + } + .navigationTitle("Timeline") + .navigationDestination(for: Message.ID.self) { id in + MessageDetailView(messageID: id) + } + } + .task { + // Build the view model once we have the environment in + // hand; SwiftUI doesn't expose `@Environment` during + // `init`, so deferred construction inside `.task` is the + // canonical pattern. + if viewModel == nil, let environment { + let model = TimelineViewModel(messages: environment.messages) + viewModel = model + await model.initialLoad() + } + } + } + + // MARK: - Body sections + + @ViewBuilder + private func timelineBody(viewModel: TimelineViewModel) -> some View { + VStack(spacing: 0) { + toolbar(viewModel: viewModel) + Divider() + content(viewModel: viewModel) + } + .refreshable { + await viewModel.refresh() + } + } + + @ViewBuilder + private func toolbar(viewModel: TimelineViewModel) -> some View { + HStack(spacing: 12) { + Picker( + "Scope", + selection: Binding( + get: { viewModel.scope }, + set: { newValue in + Task { await viewModel.changeScope(newValue) } + } + ) + ) { + Text("All").tag(TimelineScope.all) + Text("Mine").tag(TimelineScope.mine) + } + .pickerStyle(.segmented) + .frame(maxWidth: 220) + .accessibilityLabel("Timeline scope") + + HStack(spacing: 6) { + Image(systemName: "number") + .foregroundStyle(.secondary) + TextField("Filter by tag", text: $tagDraft) + .textFieldStyle(.roundedBorder) + .onSubmit { + Task { await viewModel.setTagFilter(tagDraft) } + } + if !tagDraft.isEmpty { + Button { + tagDraft = "" + Task { await viewModel.setTagFilter(nil) } + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Clear tag filter") + } + } + .frame(maxWidth: 280) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private func content(viewModel: TimelineViewModel) -> some View { + if let error = viewModel.error, viewModel.messagesLoaded.isEmpty { + errorState(error: error, viewModel: viewModel) + } else if viewModel.messagesLoaded.isEmpty, viewModel.isLoading { + loadingState + } else if viewModel.messagesLoaded.isEmpty { + emptyState + } else { + messageList(viewModel: viewModel) + } + } + + @ViewBuilder + private func messageList(viewModel: TimelineViewModel) -> some View { + List(selection: $selection) { + ForEach(viewModel.messagesLoaded) { message in + NavigationLink(value: message.id) { + MessageRowView(message: message) + } + .onAppear { + // Trigger paging when we surface the row that's + // five from the bottom — keeps scroll smooth and + // never fires while a load is in flight (the view + // model gates). + if shouldLoadMore(for: message, in: viewModel.messagesLoaded) { + Task { await viewModel.loadMore() } + } + } + } + if viewModel.isLoading && !viewModel.messagesLoaded.isEmpty { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } + .padding(.vertical, 8) + } + } + .listStyle(.inset) + } + + private var loadingState: some View { + VStack(spacing: 8) { + ProgressView() + Text("Loading timeline…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No messages") + .font(.headline) + Text("Posts in this feed will appear here.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func errorState(error: Error, viewModel: TimelineViewModel) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + Text("Couldn't load the timeline") + .font(.headline) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Try again") { + Task { await viewModel.refresh() } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var unconfiguredState: some View { + // Hit only if the scene wasn't wired through `AppEnvironment`, + // which is a programmer error rather than a runtime one — keep + // the message diagnostic rather than user-facing. + VStack(spacing: 8) { + Image(systemName: "wrench.adjustable") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("Timeline unavailable") + .font(.headline) + Text("AppEnvironment is not injected into the view tree.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Helpers + + private func shouldLoadMore(for message: Message, in loaded: [Message]) -> Bool { + guard let index = loaded.firstIndex(where: { $0.id == message.id }) else { return false } + return index >= max(0, loaded.count - 5) + } +} diff --git a/App/Features/Timeline/TimelineViewModel.swift b/App/Features/Timeline/TimelineViewModel.swift new file mode 100644 index 0000000..7006238 --- /dev/null +++ b/App/Features/Timeline/TimelineViewModel.swift @@ -0,0 +1,164 @@ +// TimelineViewModel +// +// Drives `TimelineRootView`: owns the current scope / tag filter, the +// loaded pages, and the loading & error state. Reads through +// `MessagesServicing` only — no direct API or cache access — so unit +// tests substitute a stub service trivially (PLAN.md §3, §7). +// +// Initial loads consume `timelineStream(...)` so the cached page +// renders instantly and the fresh page replaces it in place +// (stale-while-revalidate per PLAN.md §5). Subsequent pages use the +// throwing `timeline(...)` call and append. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class TimelineViewModel { + + // MARK: - Inputs / configuration + + /// Page size for both the initial load and infinite scroll. Mirrors + /// the API's default `limit` so we always ask for full pages. + static let pageSize: Int = 20 + + private let messages: MessagesServicing + + // MARK: - Observable state + + /// Currently selected scope (All / Mine). + private(set) var scope: TimelineScope + /// Active tag filter, or `nil` for the unfiltered feed. + private(set) var tagFilter: String? + /// Pages loaded so far, in display order. + private(set) var messagesLoaded: [Message] = [] + /// True while a network round-trip is in flight (initial, refresh, + /// or load-more). + private(set) var isLoading: Bool = false + /// Surfaced error from the most recent failed load. Cleared on the + /// next successful round-trip. + private(set) var error: Error? + /// Whether the server reports more pages beyond what's loaded. + private(set) var hasMore: Bool = false + /// The `offset` to pass on the next `loadMore` call. `nil` when + /// `hasMore` is false. + private(set) var nextOffset: Int? + + // MARK: - Init + + init(messages: MessagesServicing, scope: TimelineScope = .all, tagFilter: String? = nil) { + self.messages = messages + self.scope = scope + self.tagFilter = tagFilter + } + + // MARK: - Intents + + /// First-time load for the current scope + tag. Consumes the + /// stale-while-revalidate stream so the cached page renders + /// immediately and is then replaced by the fresh page. Safe to + /// call repeatedly; each call resets paging state and starts over. + func initialLoad() async { + await load(reset: true, useStream: true) + } + + /// Re-fetches page zero in place (`.refreshable` handler). Skips + /// the cache, so the user sees the live result. + func refresh() async { + await load(reset: true, useStream: false) + } + + /// Appends the next page when one exists. No-op while a load is in + /// flight or when `hasMore` is false — the view calls this on the + /// trailing row's `.onAppear` and we de-dupe here rather than push + /// the responsibility onto the view. + func loadMore() async { + guard !isLoading, hasMore, let offset = nextOffset else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await messages.timeline( + scope: scope, + tag: tagFilter, + limit: Self.pageSize, + offset: offset + ) + messagesLoaded.append(contentsOf: page.messages) + hasMore = page.hasMore + nextOffset = page.nextOffset + error = nil + } catch { + self.error = error + } + } + + /// Switches scope and reloads from page zero. No-op when the scope + /// is unchanged so toolbar bindings don't trigger spurious reloads. + func changeScope(_ newScope: TimelineScope) async { + guard newScope != scope else { return } + scope = newScope + await load(reset: true, useStream: true) + } + + /// Sets (or clears) the tag filter and reloads from page zero. The + /// filter is treated as a single string for M1; tag-token UI is M2+. + func setTagFilter(_ tag: String?) async { + let normalized = tag?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolved = (normalized?.isEmpty ?? true) ? nil : normalized + guard resolved != tagFilter else { return } + tagFilter = resolved + await load(reset: true, useStream: true) + } + + // MARK: - Internals + + private func load(reset: Bool, useStream: Bool) async { + if reset { + messagesLoaded = [] + hasMore = false + nextOffset = nil + } + isLoading = true + error = nil + defer { isLoading = false } + + if useStream { + do { + for try await page in messages.timelineStream( + scope: scope, + tag: tagFilter, + limit: Self.pageSize, + offset: 0 + ) { + apply(page, reset: true) + } + } catch { + self.error = error + } + } else { + do { + let page = try await messages.timeline( + scope: scope, + tag: tagFilter, + limit: Self.pageSize, + offset: 0 + ) + apply(page, reset: true) + } catch { + self.error = error + } + } + } + + private func apply(_ page: TimelinePage, reset: Bool) { + if reset { + messagesLoaded = page.messages + } else { + messagesLoaded.append(contentsOf: page.messages) + } + hasMore = page.hasMore + nextOffset = page.nextOffset + } +} diff --git a/App/InterlinedListApp.swift b/App/InterlinedListApp.swift index 521dc68..958195c 100644 --- a/App/InterlinedListApp.swift +++ b/App/InterlinedListApp.swift @@ -1,17 +1,29 @@ // InterlinedListApp // -// App entry point. The full scene graph (compose window, document windows, -// onboarding) is wired up in later milestones. M0 ships a minimal -// NavigationSplitView placeholder plus a Settings scene so the target -// builds, launches, and reflects the sidebar sections from PLAN.md §3 / §5. +// App entry point. Constructs the composition root (`AppEnvironment`) +// once at launch and injects it into the view tree so every feature +// view model reads services from the environment rather than +// constructing them itself (PLAN.md §3). +// +// The full scene graph (compose window, document windows, onboarding) +// is wired up in later milestones. M1 ships a NavigationSplitView with +// a real Timeline detail and placeholders for the remaining six +// sidebar sections, plus a Settings placeholder scene. import SwiftUI @main struct InterlinedListApp: App { + + /// Composition root constructed once for the app's lifetime. + /// `@StateObject` so the same instance survives view-tree rebuilds. + @StateObject private var environment = AppEnvironment.live() + var body: some Scene { WindowGroup { MainWindowView() + .environmentObject(environment) + .environment(\.appEnvironment, environment) } .windowToolbarStyle(.unified) diff --git a/App/Navigation/MainWindowView.swift b/App/Navigation/MainWindowView.swift index 19e4712..98b1e6c 100644 --- a/App/Navigation/MainWindowView.swift +++ b/App/Navigation/MainWindowView.swift @@ -1,10 +1,14 @@ // MainWindowView // -// M0 placeholder for the app's main window. Renders a NavigationSplitView -// with the seven sidebar sections enumerated in PLAN.md §5 (Timeline, -// Scheduled, Notifications, Lists, Documents, Organizations, Profile) — -// static labels only; no behaviour. Real navigation routing arrives with -// the per-feature work in later waves. +// The app's main window: a `NavigationSplitView` with the seven +// sidebar sections enumerated in PLAN.md §5 (Timeline, Scheduled, +// Notifications, Lists, Documents, Organizations, Profile). +// +// The sidebar is fixed; the detail column is driven by the +// `SidebarDetailDispatcher` below, which switches on `SidebarSection` +// and returns the per-feature root view. Each feature owns its own +// folder under `App/Features//` and replaces its case in the +// dispatcher when it lands — every other line stays untouched. import SwiftUI @@ -47,7 +51,7 @@ struct MainWindowView: View { .navigationSplitViewColumnWidth(min: 180, ideal: 220) } detail: { if let selection { - PlaceholderDetailView(section: selection) + SidebarDetailDispatcher(section: selection) } else { Text("Select a section") .foregroundStyle(.secondary) @@ -56,24 +60,46 @@ struct MainWindowView: View { } } -private struct PlaceholderDetailView: View { +// MARK: - SidebarDetailDispatcher +// +// Single switch that maps a sidebar section to its detail view. This is +// the *only* place feature roots are constructed for the main window. +// +// Extension contract for parallel feature agents: +// - To wire up a new feature, change exactly one line in this switch +// to point at the new feature root (e.g. `case .lists: ListsBrowserView()`). +// - Do not add cross-cutting state here. Anything richer than "swap +// one case to its new root view" belongs inside the feature folder. +// - Cases that have not landed yet render their original +// `*PlaceholderView` so the app keeps building. + +private struct SidebarDetailDispatcher: View { let section: SidebarSection var body: some View { - VStack(spacing: 12) { - Image(systemName: section.systemImage) - .font(.system(size: 48)) - .foregroundStyle(.secondary) - Text(section.rawValue) - .font(.title) - Text("Coming soon") - .foregroundStyle(.secondary) + switch section { + case .timeline: + TimelineRootView() + case .scheduled: + // Scheduled posts ship in M6 with cross-posting (PLAN.md §6). + // No dedicated folder exists yet; the compose placeholder is + // the closest stand-in until the scheduled-posts UI lands. + ComposePlaceholderView() + case .notifications: + NotificationsPlaceholderView() + case .lists: + ListsBrowserView() + case .documents: + DocumentsPlaceholderView() + case .organizations: + OrganizationsPlaceholderView() + case .profile: + ProfileRootView() } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle(section.rawValue) } } #Preview { MainWindowView() + .environmentObject(AppEnvironment.live()) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListDetail.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListDetail.swift new file mode 100644 index 0000000..e4c948c --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListDetail.swift @@ -0,0 +1,40 @@ +import Foundation + +/// The single-list public view — extends `ListSummary` with the schema DSL +/// string the M3 typed-row editor will parse (PLAN.md §1 "Structured lists", +/// §6 M3). +/// +/// Maps from `ListDTO` returned by `GET /api/users/[username]/lists/[slug]`. +/// In M1 the schema is carried as the raw DSL string only — parsing it into +/// typed columns is M3 work (the schema DSL parser ships with the M3 lists +/// milestone, per PLAN.md §6 / §7). The string is exposed here so the M1 UI +/// can still surface a human-readable schema description ("Title:text, +/// Year:number") above the row table. +public struct ListDetail: Sendable, Equatable, Identifiable { + public let summary: ListSummary + /// The schema DSL string from the API (e.g. `"Title:text, Year:number"`). + /// `nil` when the API omits it — older or unstructured lists may have no + /// schema set yet. + public let schemaDescription: String? + /// Optional parent list id for nested lists (PLAN.md §1 "Nested lists"). + /// The hierarchy UI is a later-milestone deliverable; the field is + /// surfaced here so detail views render the breadcrumb when present. + public let parentID: String? + + public var id: String { summary.id } + public var title: String { summary.title } + public var description: String? { summary.description } + public var visibility: Visibility { summary.visibility } + public var createdAt: Date? { summary.createdAt } + public var updatedAt: Date? { summary.updatedAt } + + public init( + summary: ListSummary, + schemaDescription: String? = nil, + parentID: String? = nil + ) { + self.summary = summary + self.schemaDescription = schemaDescription + self.parentID = parentID + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift new file mode 100644 index 0000000..10b2315 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListMappers.swift @@ -0,0 +1,103 @@ +import Foundation +import InterlinedKit + +// MARK: - List DTO → domain mapping +// +// Lives in its own file rather than `Mappers.swift` so the lists/profile +// surface added in M1 task 1B does not collide on the same file as the +// messages/user mappers added in task 1A. Same audit-in-one-place rule +// applies (PLAN.md §3 — DTOs never cross into the UI); this file is just the +// per-group slice. + +extension ListSummary { + /// Maps the lightweight list-row projection used by the public browse + /// collection. The collection endpoint omits `schema` and (typically) + /// `description` detail; this initializer only relies on the always- + /// present `id` / `title` and treats `isPublic` as `true` by default + /// because the public browse routes only ever return public lists. + public init(from dto: ListDTO) { + self.init( + id: dto.id, + title: dto.title, + description: dto.description, + visibility: Visibility(publiclyVisible: dto.isPublic ?? true), + createdAt: dto.createdAt, + updatedAt: dto.updatedAt + ) + } +} + +extension ListsPage { + /// Builds a page from the kit's `Paginated` envelope, mapping + /// each row and deriving the next-page cursor from the pagination block — + /// the same shape `TimelinePage.init(from:)` uses. + public init(from paginated: Paginated) { + let lists = paginated.items.map(ListSummary.init(from:)) + let info = paginated.pagination + self.init( + lists: lists, + hasMore: info.hasMore, + nextOffset: info.hasMore ? info.offset + info.limit : nil + ) + } +} + +extension ListDetail { + /// Maps the detail response (`GET /api/users/[username]/lists/[slug]`). + /// The schema DSL string is carried through verbatim — parsing is M3. + public init(from dto: ListDTO) { + self.init( + summary: ListSummary(from: dto), + schemaDescription: dto.schema, + parentID: dto.parentId + ) + } +} + +// MARK: - Row mapping + +extension ListCellValue { + /// Recursive projection of the kit's wire-faithful `ListJSONValue` into + /// the domain's loose cell type. Arrays and objects map recursively so the + /// projection is total (every DTO value has exactly one domain value). + public init(from value: ListJSONValue) { + switch value { + case .null: self = .null + case .bool(let v): self = .bool(v) + case .int(let v): self = .int(v) + case .double(let v): self = .double(v) + case .string(let v): self = .string(v) + case .array(let items): self = .array(items.map(ListCellValue.init(from:))) + case .object(let dict): + self = .object(dict.mapValues(ListCellValue.init(from:))) + } + } +} + +extension ListRow { + /// Maps one row DTO, projecting every cell through `ListCellValue` so the + /// view layer never touches `ListJSONValue` directly. + public init(from dto: ListRowDTO) { + self.init( + id: dto.id, + listID: dto.listId, + fields: dto.rowData.mapValues(ListCellValue.init(from:)), + createdAt: dto.createdAt, + updatedAt: dto.updatedAt + ) + } +} + +extension RowsPage { + /// Builds a page from the paginated row envelope, deriving the cursor the + /// same way the other `*Page` initializers do. + public init(from paginated: Paginated) { + let rows = paginated.items.map(ListRow.init(from:)) + let info = paginated.pagination + self.init( + rows: rows, + hasMore: info.hasMore, + nextOffset: info.hasMore ? info.offset + info.limit : nil + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListRow.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListRow.swift new file mode 100644 index 0000000..6e62077 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListRow.swift @@ -0,0 +1,85 @@ +import Foundation +import InterlinedKit + +/// A loose, type-erased cell value for a list row in M1. +/// +/// The kit's `ListJSONValue` is the wire-faithful tagged union; the M1 read- +/// only UI does not yet need the typed schema, so the domain exposes a +/// projection that the view layer can render as text directly. The M3 schema +/// editor work will introduce a typed-per-column accessor that interprets +/// these values against the parsed schema (PLAN.md §6 M3, §7 "Schema DSL +/// parser"). +public enum ListCellValue: Sendable, Equatable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + /// A JSON array — rendered as `"[...]"` in M1; structured rendering lands + /// when the schema editor does. + case array([ListCellValue]) + /// A JSON object — rendered as `"{...}"` in M1. + case object([String: ListCellValue]) + + /// Human-readable rendering for the M1 read-only grid. Plain string-like + /// values render as themselves; structured values render compactly so the + /// table never shows a raw Swift `Optional(.array(...))` dump. + public var displayText: String { + switch self { + case .null: return "" + case .bool(let v): return v ? "true" : "false" + case .int(let v): return String(v) + case .double(let v): return String(v) + case .string(let v): return v + case .array(let v): return "[\(v.count) items]" + case .object(let v): return "{\(v.count) fields}" + } + } +} + +/// A single dynamic-schema row, projected as a field-name → cell map. +/// +/// Maps from `ListRowDTO`. M1 keeps the row shape loose so the read-only +/// browser can render rows without the schema DSL parser; M3 introduces the +/// typed accessor that pairs each cell with its declared column type. +public struct ListRow: Sendable, Equatable, Identifiable { + public let id: String + /// The owning list id when the row payload includes it. + public let listID: String? + /// Field-name → cell value, in no defined order. The schema string on the + /// owning `ListDetail` defines the canonical column order for rendering. + public let fields: [String: ListCellValue] + public let createdAt: Date? + public let updatedAt: Date? + + public init( + id: String, + listID: String? = nil, + fields: [String: ListCellValue], + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.listID = listID + self.fields = fields + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +/// One page of rows from a public list — the same shape as `ListsPage` / +/// `TimelinePage` so the App layer's pagination machinery is uniform. +public struct RowsPage: Sendable, Equatable { + public let rows: [ListRow] + public let hasMore: Bool + public let nextOffset: Int? + + public init(rows: [ListRow], hasMore: Bool, nextOffset: Int?) { + self.rows = rows + self.hasMore = hasMore + self.nextOffset = nextOffset + } + + /// An empty page with no further results — used when a list has no rows. + public static let empty = RowsPage(rows: [], hasMore: false, nextOffset: nil) +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListSummary.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListSummary.swift new file mode 100644 index 0000000..57a1bc5 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListSummary.swift @@ -0,0 +1,62 @@ +import Foundation + +/// A list as it appears in a user's public list collection — the lightweight +/// projection the M1 browser shows in a sidebar or grid (PLAN.md §1 "Public +/// list browsing", §6 M1). +/// +/// Maps from `ListDTO` rows returned by `GET /api/users/[username]/lists`. +/// That collection endpoint omits per-list detail (schema, row count) so this +/// shape only carries what the public browse view needs: identity, title, +/// description, visibility, timestamps. Use `ListDetail` for the single-list +/// view that includes the schema string. +public struct ListSummary: Sendable, Equatable, Hashable, Identifiable { + /// The list's stable id (slug or uuid depending on API path used). The + /// public browse routes accept either an id or a slug as the path + /// component, so this is treated as an opaque string identifier. + public let id: String + public let title: String + /// The description blurb, when set on the list. + public let description: String? + /// `nil` when the collection row omits the field. The public browse routes + /// only return public lists, so this defaults to `.public` in mapping when + /// the API does not include the flag. + public let visibility: Visibility + public let createdAt: Date? + public let updatedAt: Date? + + public init( + id: String, + title: String, + description: String? = nil, + visibility: Visibility = .public, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.title = title + self.description = description + self.visibility = visibility + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +/// One page of a public-list browse: the summaries plus the next-page cursor. +/// +/// Mirrors `TimelinePage` precisely so the App layer's infinite-scroll +/// machinery is shared across feature areas. +public struct ListsPage: Sendable, Equatable { + public let lists: [ListSummary] + public let hasMore: Bool + public let nextOffset: Int? + + public init(lists: [ListSummary], hasMore: Bool, nextOffset: Int?) { + self.lists = lists + self.hasMore = hasMore + self.nextOffset = nextOffset + } + + /// An empty page with no further results — the boundary value used when a + /// user has no public lists. + public static let empty = ListsPage(lists: [], hasMore: false, nextOffset: nil) +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift new file mode 100644 index 0000000..5b78f40 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift @@ -0,0 +1,114 @@ +import Foundation +import InterlinedKit + +// MARK: - Profile / Social DTO → domain mapping +// +// Sibling to `ListMappers.swift` — per-group slice of the audit-in-one-place +// mapper convention (PLAN.md §3). The follow/profile surface combines two +// kit DTOs (`FollowUserDTO`, `FollowCountsDTO`) and is stitched into the +// domain `UserProfile` by `SocialService`. + +extension UserSummary { + /// Maps the Follow group's bare-bones user shape. `displayName` falls back + /// to `username` so the UI always has something to render; if neither is + /// present (the API reference allows both to be optional on this DTO) we + /// fall back to the id rather than crash. + public init(from dto: FollowUserDTO) { + let username = dto.username ?? dto.id + self.init( + id: dto.id, + username: username, + displayName: dto.displayName ?? username, + avatarURL: dto.avatarUrl.flatMap(URL.init(string:)) + ) + } +} + +extension UserProfile { + /// Builds a public profile from the authenticated-account DTO. Used when + /// the only profile data the client has is the signed-in user's own + /// `GET /api/user` payload — viewing your own profile through the same + /// projection the public view uses. + /// + /// The dedicated public-profile API endpoint (`GET /api/users/[username]`) + /// is not yet exposed by the kit; see the M1 task 1B report for the + /// follow-up kit work needed to surface other users' profiles by + /// username. + public init(from dto: UserDTO) { + let summary = UserSummary( + id: dto.id, + username: dto.username, + displayName: dto.displayName ?? dto.username, + avatarURL: dto.avatar.flatMap(URL.init(string:)) + ) + self.init( + summary: summary, + bio: dto.bio, + followerCount: nil, + followingCount: nil, + isPrivate: dto.isPrivateAccount ?? false, + joinedAt: dto.createdAt + ) + } + + /// Returns a copy with the counts populated — used by `SocialService` to + /// stitch the identity payload together with the `/api/follow/[id]/counts` + /// response without making the model mutable. + public func withCounts(_ counts: FollowCountsDTO) -> UserProfile { + UserProfile( + summary: summary, + bio: bio, + followerCount: counts.followerCount, + followingCount: counts.followingCount, + isPrivate: isPrivate, + joinedAt: joinedAt + ) + } + + /// Projects a `UserProfile` from the embedded author on a `MessageDTO`, + /// the reduced-scope fallback used by `SocialService.profile(username:)` + /// (decision 0002 — public-profile fallback). + /// + /// The embedded author shape (`UserSummaryDTO`) carries only identity: + /// `id`, `username`, `displayName`, `avatar`. Everything richer on + /// `UserProfile` (`bio`, `followerCount`, `followingCount`, `joinedAt`) + /// is unavailable from this source and is therefore left **definitely + /// nil** until an upstream profile endpoint exists; `isPrivate` defaults + /// to `false` for the same reason (we cannot know without an account + /// payload, and assuming "public" matches the source — the user clearly + /// has at least one public message). Callers needing follower / following + /// counts must call `SocialService.counts(of:)` separately once the + /// `id` from this projection is known. + public init(fromEmbeddedAuthorOf message: MessageDTO) { + let dto = message.user + let summary = UserSummary( + id: dto.id, + username: dto.username, + displayName: dto.displayName ?? dto.username, + avatarURL: dto.avatar.flatMap(URL.init(string:)) + ) + self.init( + summary: summary, + bio: nil, + followerCount: nil, + followingCount: nil, + isPrivate: false, + joinedAt: nil + ) + } +} + +extension UsersPage { + /// Maps the bare-array shape `GET /api/follow/[userId]/followers` and + /// `/following` return today. The cursor fields default to "no more", + /// matching the bare-array reality; if the kit later switches these to + /// `Paginated` a sibling initializer can be added without + /// changing call sites. + public init(from dtos: [FollowUserDTO]) { + self.init( + users: dtos.map(UserSummary.init(from:)), + hasMore: false, + nextOffset: nil + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift new file mode 100644 index 0000000..6bfafe3 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/UserProfile.swift @@ -0,0 +1,74 @@ +import Foundation + +/// A public user profile as the M1 read-only profile UI renders it (PLAN.md +/// §1 "Follow system" / "Profile & account", §6 M1). +/// +/// This is the *public* projection — the fields any signed-in viewer can see +/// of another user. The `CurrentUser` model carries the additional account- +/// only fields (email, pending email, subscriber status, API keys). +/// +/// The profile combines two API sources: a public user lookup (identity, +/// bio, avatar, account flags) and `GET /api/follow/[userId]/counts` for the +/// follower/following totals. The owning `SocialService` is responsible for +/// stitching them together so the view sees one model. +public struct UserProfile: Sendable, Equatable, Hashable, Identifiable { + /// The compact identity shared with the message-card projection. Reusing + /// `UserSummary` keeps avatar handling and display-name fallback in one + /// place across the app. + public let summary: UserSummary + /// The user's bio / about line. `nil` when unset. + public let bio: String? + /// Total followers, when the API surfaced the counts call. `nil` when the + /// stitch was skipped (e.g. counts call failed but identity succeeded). + public let followerCount: Int? + /// Total accounts this user follows. `nil` for the same reason as above. + public let followingCount: Int? + /// When set, the account is private — follow requests require approval + /// before posts become visible (PLAN.md §1 "Follow system" / "request + /// approval for private accounts"). + public let isPrivate: Bool + /// Account creation timestamp, when the API returns it. + public let joinedAt: Date? + + public var id: String { summary.id } + public var username: String { summary.username } + public var displayName: String { summary.displayName } + public var avatarURL: URL? { summary.avatarURL } + + public init( + summary: UserSummary, + bio: String? = nil, + followerCount: Int? = nil, + followingCount: Int? = nil, + isPrivate: Bool = false, + joinedAt: Date? = nil + ) { + self.summary = summary + self.bio = bio + self.followerCount = followerCount + self.followingCount = followingCount + self.isPrivate = isPrivate + self.joinedAt = joinedAt + } +} + +/// One page of users from a follower / following lookup, mirroring the other +/// `*Page` shapes. The follow list endpoints currently return bare arrays +/// rather than the paginated envelope; the kit may switch them to +/// `Paginated` later (see `FollowEndpoint.swift` note). When +/// that happens the `hasMore` / `nextOffset` fields here will start carrying +/// real values; today they default to `false` / `nil`. +public struct UsersPage: Sendable, Equatable { + public let users: [UserSummary] + public let hasMore: Bool + public let nextOffset: Int? + + public init(users: [UserSummary], hasMore: Bool = false, nextOffset: Int? = nil) { + self.users = users + self.hasMore = hasMore + self.nextOffset = nextOffset + } + + /// An empty page with no further results. + public static let empty = UsersPage(users: [], hasMore: false, nextOffset: nil) +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift new file mode 100644 index 0000000..2e199b2 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift @@ -0,0 +1,113 @@ +import Foundation +import InterlinedKit + +// MARK: - ListsServicing + +/// The public-list browse surface the App layer codes against for the M1 +/// read-only core (PLAN.md §1 "Public list browsing", §6 M1). +/// +/// M1 covers the three no-auth browse endpoints under +/// `/api/users/[username]/lists*` only — no list CRUD, no schema editing, no +/// watchers, no GitHub refresh. Those land in M3 as a separate, authenticated +/// surface on this same service (or a sibling `MyListsServicing`). +/// +/// Follows the same DI shape as `MessagesServicing`: takes its +/// `APIClientProtocol` as a parameter so unit tests run against a stub. +public protocol ListsServicing: Sendable { + + /// Loads one page of `username`'s public lists. Mirrors + /// `MessagesServicing.timeline` — the App layer's infinite-scroll machinery + /// is uniform across the read surfaces. + func publicLists(username: String, limit: Int, offset: Int) async throws -> ListsPage + + /// Loads a single public list by its slug (or id — the path accepts either). + func publicList(username: String, slug: String) async throws -> ListDetail + + /// Loads one page of rows from a public list. The row shape is loose for + /// M1 (`[String: ListCellValue]`); the typed schema-aware editor lands in + /// M3. + func publicRows( + username: String, + slug: String, + limit: Int, + offset: Int + ) async throws -> RowsPage +} + +// MARK: - ListsService + +public final class ListsService: ListsServicing { + + private let api: APIClientProtocol + private let decoder: JSONDecoder + + /// - Parameters: + /// - api: the networking seam (a stub in tests). + /// - decoder: shared kit JSON configuration. Defaults to the kit's + /// `JSONCoders` decoder so dates parse identically to the client. + public init( + api: APIClientProtocol, + decoder: JSONDecoder = JSONCoders.makeDecoder() + ) { + self.api = api + self.decoder = decoder + } + + // MARK: Public browse + + public func publicLists( + username: String, + limit: Int, + offset: Int + ) async throws -> ListsPage { + let request = Lists.publicLists( + username: username, + limit: limit, + offset: offset + ) + let (data, _) = try await api.sendRaw(request) + let key = request.paginationKey ?? "data" + let paginated = try PaginatedDecoder.decode( + ListDTO.self, + collectionKey: key, + from: data, + decoder: decoder + ) + return ListsPage(from: paginated) + } + + public func publicList( + username: String, + slug: String + ) async throws -> ListDetail { + // The kit's `publicList(username:id:)` accepts an id or a slug — the + // path component is opaque to the request builder. The domain + // parameter is named `slug` to match the M1 UI vocabulary while still + // accepting either form. + let dto = try await api.send(Lists.publicList(username: username, id: slug)) + return ListDetail(from: dto) + } + + public func publicRows( + username: String, + slug: String, + limit: Int, + offset: Int + ) async throws -> RowsPage { + let request = Lists.publicListRows( + username: username, + id: slug, + limit: limit, + offset: offset + ) + let (data, _) = try await api.sendRaw(request) + let key = request.paginationKey ?? "data" + let paginated = try PaginatedDecoder.decode( + ListRowDTO.self, + collectionKey: key, + from: data, + decoder: decoder + ) + return RowsPage(from: paginated) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift new file mode 100644 index 0000000..e37c332 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift @@ -0,0 +1,177 @@ +import Foundation +import InterlinedKit + +// MARK: - SocialError + +/// Domain-level errors surfaced by `SocialService`. +/// +/// Kept separate from `APIError` because these failures originate in the +/// domain layer's stitching/derivation logic, not in the HTTP boundary. Any +/// transport / decode / status failure from the kit propagates as `APIError` +/// unchanged — callers should `catch` both types. +public enum SocialError: Error, Sendable, Equatable { + + /// The username has no public-facing data the M1 fallback can read from. + /// + /// Background (decision 0002 — public-profile fallback): the API does not + /// expose `GET /api/users/[username]`. `SocialService.profile(username:)` + /// derives identity from the embedded author on the user's public messages + /// (`GET /api/user/[username]/messages`). When that list is empty there is + /// no embedded author to project, so the profile cannot be built. This + /// case will go away in a later milestone once an upstream profile + /// endpoint exists. + case profileUnavailable(username: String) +} + +extension SocialError: LocalizedError, CustomStringConvertible { + public var errorDescription: String? { description } + + public var description: String { + switch self { + case .profileUnavailable(let username): + return "No public profile data available for @\(username)." + } + } +} + +// MARK: - SocialServicing + +/// The read-only social surface the App layer codes against for the M1 +/// profile UI (PLAN.md §1 "Follow system", §6 M1). +/// +/// M1 scope is strictly **read-only**: profile reads, follower/following +/// reads, follow-status reads. The follow/unfollow + request-approval write +/// surface lands in M5 (PLAN.md §6 M5) — those methods are deliberately +/// omitted here and gated with `// M5:` markers at the protocol level so the +/// next milestone has a clear extension point. +/// +/// ## Kit-endpoint gap (decision 0002) +/// +/// The upstream API does not expose `GET /api/users/[username]` (verified by +/// live probe — 404, not in docs). The only cross-user identity source today +/// is the embedded author on a user's public messages. `profile(username:)` +/// is implemented as a reduced-scope read backed by that embedded user, and +/// the richer profile (bio, joinedAt, isPrivate, follower counts) ships in +/// a later milestone once the upstream API exposes a profile endpoint. +public protocol SocialServicing: Sendable { + + /// Loads a public profile by username. + /// + /// **M1 reduced scope (decision 0002):** identity (`id`, `username`, + /// `displayName`, `avatarURL`) is derived from the embedded author on + /// the user's most recent public message. Bio, joined-at, private-account + /// flag, follower and following counts are **not available** from this + /// fallback path and will be `nil` / `false`. The caller is expected to + /// fetch follower / following counts separately via `counts(of:)` when + /// the userId is known. + /// + /// - Throws: + /// - `SocialError.profileUnavailable(username:)` when the user has zero + /// public messages — there is no embedded author to project from in + /// that case. Removed in a later milestone once a true profile + /// endpoint exists. + /// - `APIError` (e.g. `.notFound`) propagated from the kit when the + /// username does not exist or the request otherwise fails. + func profile(username: String) async throws -> UserProfile + + /// Loads the relationship status between the signed-in user and `userId`. + /// Used by the profile UI to render the follow button state without + /// performing any write. + func status(of userId: String) async throws -> FollowStatusDTO + + /// Loads follower / following counts for `userId`. Cheap call that powers + /// the header stats on a profile. + func counts(of userId: String) async throws -> FollowCountsDTO + + /// Loads the followers list for `userId`. Bare-array shape today (see + /// `FollowEndpoint.swift` note); the `UsersPage.hasMore` is always + /// `false` until the kit switches this endpoint to the paginated envelope. + func followers(of userId: String, limit: Int, offset: Int) async throws -> UsersPage + + /// Loads the following list for `userId`. Same bare-array caveat as + /// `followers`. + func following(of userId: String, limit: Int, offset: Int) async throws -> UsersPage + + // M5: follow(userId:), unfollow(userId:), approve(userId:), reject(userId:), + // remove(userId:), and the pending-requests inbox. Endpoint builders are + // already in `Follow` — wiring them in M5 alongside the request-approval + // UI work. +} + +// MARK: - SocialService + +public final class SocialService: SocialServicing { + + private let api: APIClientProtocol + private let decoder: JSONDecoder + + public init( + api: APIClientProtocol, + decoder: JSONDecoder = JSONCoders.makeDecoder() + ) { + self.api = api + self.decoder = decoder + } + + // MARK: Profile + + public func profile(username: String) async throws -> UserProfile { + // Decision 0002: no `GET /api/users/[username]` endpoint exists. The + // public-messages endpoint embeds the author user object on every + // `MessageDTO`, which is the only cross-user identity source today. + // Pull a single message (limit 1, offset 0) and project from the + // embedded user — we deliberately do not fan out into a full feed. + let request = Messages.userMessages(username: username, limit: 1, offset: 0) + let (data, _) = try await api.sendRaw(request) + let key = request.paginationKey ?? "messages" + let paginated = try PaginatedDecoder.decode( + MessageDTO.self, + collectionKey: key, + from: data, + decoder: decoder + ) + guard let first = paginated.items.first else { + // Empty path: no message means no embedded author to derive from. + // Documented M1 limitation — see `SocialError.profileUnavailable`. + throw SocialError.profileUnavailable(username: username) + } + return UserProfile(fromEmbeddedAuthorOf: first) + } + + // MARK: Relationship reads + + public func status(of userId: String) async throws -> FollowStatusDTO { + try await api.send(Follow.status(userId: userId)) + } + + public func counts(of userId: String) async throws -> FollowCountsDTO { + try await api.send(Follow.counts(userId: userId)) + } + + // MARK: Follower / following lists + + public func followers( + of userId: String, + limit: Int, + offset: Int + ) async throws -> UsersPage { + // The kit returns the bare array shape today. Limit/offset are + // accepted for forward compatibility — when the kit switches to + // `Paginated` they will start being sent through. + // For M1 the parameters are unused at the wire layer but kept in the + // signature so callers do not need to change later. + _ = (limit, offset) + let dtos = try await api.send(Follow.followers(userId: userId)) + return UsersPage(from: dtos) + } + + public func following( + of userId: String, + limit: Int, + offset: Int + ) async throws -> UsersPage { + _ = (limit, offset) + let dtos = try await api.send(Follow.following(userId: userId)) + return UsersPage(from: dtos) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListsServiceTests.swift new file mode 100644 index 0000000..51f2eec --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListsServiceTests.swift @@ -0,0 +1,238 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `ListsService` (M1 task 1B — PLAN.md §1 "Public +/// list browsing", §6 M1, §7 testing). +/// +/// Minimum coverage per behavior per PLAN.md §7: happy path, invalid input +/// (boundary username), upstream API failure, and empty/boundary page. +final class ListsServiceTests: XCTestCase { + + // MARK: - publicLists + + func test_givenUserHasLists_whenLoadingPublicLists_thenMapsPageAndCursor() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedLists( + ids: ["list-1", "list-2"], + total: 40, + limit: 20, + offset: 0, + hasMore: true + )) + let service = ListsService(api: api) + + // When + let page = try await service.publicLists(username: "ada", limit: 20, offset: 0) + + // Then + XCTAssertEqual(page.lists.map(\.id), ["list-1", "list-2"]) + XCTAssertEqual(page.lists.first?.title, "Books") + XCTAssertTrue(page.hasMore) + XCTAssertEqual(page.nextOffset, 20) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/users/ada/lists") + XCTAssertEqual(recorded.first?.query["limit"], "20") + XCTAssertEqual(recorded.first?.query["offset"], "0") + } + + func test_givenUserHasNoLists_whenLoadingPublicLists_thenReturnsEmptyPage() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedLists(ids: [], total: 0, hasMore: false)) + let service = ListsService(api: api) + + // When + let page = try await service.publicLists(username: "stranger", limit: 20, offset: 0) + + // Then + XCTAssertTrue(page.lists.isEmpty) + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextOffset) + } + + func test_givenAPIFailure_whenLoadingPublicLists_thenThrows() async throws { + // Given — public endpoint returning a transport error. + let api = StubAPIClient() + await api.enqueue(failure: .transport(message: "offline")) + let service = ListsService(api: api) + + // When / Then + do { + _ = try await service.publicLists(username: "ada", limit: 20, offset: 0) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .transport(message: "offline")) + } + } + + func test_givenUnknownUsername_whenLoadingPublicLists_thenSurfacesNotFound() async throws { + // Given — invalid input case: a non-existent username yields 404. + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "user not found")) + let service = ListsService(api: api) + + // When / Then + do { + _ = try await service.publicLists(username: "ghost", limit: 20, offset: 0) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "user not found")) + } + } + + // MARK: - publicList (detail) + + func test_givenListExists_whenLoadingPublicList_thenMapsDetailAndSchema() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.listObject( + id: "books", + title: "Books", + description: "Things I have read", + isPublic: true, + schema: "Title:text, Year:number" + )) + let service = ListsService(api: api) + + // When + let detail = try await service.publicList(username: "ada", slug: "books") + + // Then + XCTAssertEqual(detail.id, "books") + XCTAssertEqual(detail.title, "Books") + XCTAssertEqual(detail.schemaDescription, "Title:text, Year:number") + XCTAssertEqual(detail.visibility, .public) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/users/ada/lists/books") + } + + func test_givenListMissingSchema_whenLoadingPublicList_thenSchemaDescriptionIsNil() async throws { + // Given — boundary: list with no schema defined yet. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.listObject(id: "raw", schema: nil)) + let service = ListsService(api: api) + + // When + let detail = try await service.publicList(username: "ada", slug: "raw") + + // Then + XCTAssertNil(detail.schemaDescription) + XCTAssertEqual(detail.id, "raw") + } + + func test_givenListNotFound_whenLoadingPublicList_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "no such list")) + let service = ListsService(api: api) + + // When / Then + do { + _ = try await service.publicList(username: "ada", slug: "missing") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "no such list")) + } + } + + // MARK: - publicRows + + func test_givenListHasRows_whenLoadingPublicRows_thenMapsRowsAndCells() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedRows( + ids: ["row-1", "row-2"], + total: 2, + limit: 20, + offset: 0, + hasMore: false + )) + let service = ListsService(api: api) + + // When + let page = try await service.publicRows( + username: "ada", + slug: "books", + limit: 20, + offset: 0 + ) + + // Then + XCTAssertEqual(page.rows.map(\.id), ["row-1", "row-2"]) + XCTAssertEqual(page.rows.first?.fields["Title"], .string("Dune")) + XCTAssertEqual(page.rows.first?.fields["Year"], .int(1965)) + XCTAssertFalse(page.hasMore) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/users/ada/lists/books/data") + } + + func test_givenListHasNoRows_whenLoadingPublicRows_thenReturnsEmptyPage() async throws { + // Given — boundary: an empty list. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedRows(ids: [], total: 0, hasMore: false)) + let service = ListsService(api: api) + + // When + let page = try await service.publicRows( + username: "ada", + slug: "empty", + limit: 20, + offset: 0 + ) + + // Then + XCTAssertTrue(page.rows.isEmpty) + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextOffset) + } + + func test_givenAPIFailure_whenLoadingPublicRows_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .rateLimited(serverMessage: "slow down", retryAfter: 5)) + let service = ListsService(api: api) + + // When / Then + do { + _ = try await service.publicRows( + username: "ada", + slug: "books", + limit: 20, + offset: 0 + ) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .rateLimited(serverMessage: "slow down", retryAfter: 5)) + } + } + + func test_givenRowsHasMore_whenLoadingPublicRows_thenNextOffsetAdvances() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedRows( + ids: ["row-1"], + total: 100, + limit: 10, + offset: 20, + hasMore: true + )) + let service = ListsService(api: api) + + // When + let page = try await service.publicRows( + username: "ada", + slug: "books", + limit: 10, + offset: 20 + ) + + // Then — cursor advances by limit when more pages remain. + XCTAssertTrue(page.hasMore) + XCTAssertEqual(page.nextOffset, 30) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.query["limit"], "10") + XCTAssertEqual(recorded.first?.query["offset"], "20") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift new file mode 100644 index 0000000..7752a08 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift @@ -0,0 +1,274 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `SocialService` (M1 task 1B — PLAN.md §1 "Follow +/// system", §6 M1 read-only surface, §7 testing). +/// +/// Minimum coverage per behavior per PLAN.md §7: happy path, invalid input, +/// upstream API failure, and empty/boundary case. +final class SocialServiceTests: XCTestCase { + + // MARK: - status + + func test_givenRelationshipExists_whenLoadingStatus_thenMapsAllFlags() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.followStatus( + following: true, + followedBy: true, + pendingRequest: false + )) + let service = SocialService(api: api) + + // When + let status = try await service.status(of: "user-42") + + // Then + XCTAssertTrue(status.following) + XCTAssertTrue(status.followedBy) + XCTAssertFalse(status.pendingRequest) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/follow/user-42/status") + } + + func test_givenStatusEndpointForbidden_whenLoadingStatus_thenThrows() async throws { + // Given — invalid input case: a private account the caller cannot see. + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "private account")) + let service = SocialService(api: api) + + // When / Then + do { + _ = try await service.status(of: "user-private") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "private account")) + } + } + + // MARK: - counts + + func test_givenUserHasFollowers_whenLoadingCounts_thenMapsBothCounts() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.followCounts(followerCount: 12, followingCount: 7)) + let service = SocialService(api: api) + + // When + let counts = try await service.counts(of: "user-42") + + // Then + XCTAssertEqual(counts.followerCount, 12) + XCTAssertEqual(counts.followingCount, 7) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/follow/user-42/counts") + } + + func test_givenBrandNewUser_whenLoadingCounts_thenBothCountsAreZero() async throws { + // Given — boundary: account with no follow relationships yet. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.followCounts(followerCount: 0, followingCount: 0)) + let service = SocialService(api: api) + + // When + let counts = try await service.counts(of: "user-new") + + // Then + XCTAssertEqual(counts.followerCount, 0) + XCTAssertEqual(counts.followingCount, 0) + } + + func test_givenCountsEndpointFails_whenLoadingCounts_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .transport(message: "offline")) + let service = SocialService(api: api) + + // When / Then + do { + _ = try await service.counts(of: "user-42") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .transport(message: "offline")) + } + } + + // MARK: - followers + + func test_givenFollowers_whenLoadingFollowers_thenMapsUserSummaries() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.followUserArray(ids: ["u-1", "u-2"])) + let service = SocialService(api: api) + + // When + let page = try await service.followers(of: "user-42", limit: 20, offset: 0) + + // Then + XCTAssertEqual(page.users.map(\.id), ["u-1", "u-2"]) + XCTAssertEqual(page.users.first?.displayName, "Ada Lovelace") + XCTAssertEqual(page.users.first?.avatarURL?.absoluteString, "https://cdn.interlinedlist.com/ada.png") + // Bare-array shape: cursor fields default to "no more". + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextOffset) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/follow/user-42/followers") + } + + func test_givenNoFollowers_whenLoadingFollowers_thenReturnsEmptyPage() async throws { + // Given — boundary: nobody follows this account. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.followUserArray(ids: [])) + let service = SocialService(api: api) + + // When + let page = try await service.followers(of: "user-lonely", limit: 20, offset: 0) + + // Then + XCTAssertTrue(page.users.isEmpty) + XCTAssertFalse(page.hasMore) + } + + func test_givenFollowersEndpointFails_whenLoadingFollowers_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .unauthorized(serverMessage: "sign in")) + let service = SocialService(api: api) + + // When / Then + do { + _ = try await service.followers(of: "user-42", limit: 20, offset: 0) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .unauthorized(serverMessage: "sign in")) + } + } + + // MARK: - following + + func test_givenFollowing_whenLoadingFollowing_thenMapsUserSummaries() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.followUserArray(ids: ["u-3"])) + let service = SocialService(api: api) + + // When + let page = try await service.following(of: "user-42", limit: 20, offset: 0) + + // Then + XCTAssertEqual(page.users.map(\.id), ["u-3"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/follow/user-42/following") + } + + func test_givenFollowingEndpointFails_whenLoadingFollowing_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: "bad id")) + let service = SocialService(api: api) + + // When / Then + do { + _ = try await service.following(of: "", limit: 20, offset: 0) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "bad id")) + } + } + + // MARK: - profile (decision 0002 — public-profile fallback) + + func test_givenUsernameWithMessages_whenLoadingProfile_thenMapsEmbeddedUser() async throws { + // Given — the username's public-messages feed has at least one entry, + // so the embedded author is available to project from. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedMessages(ids: ["m-1"])) + let service = SocialService(api: api) + + // When + let profile = try await service.profile(username: "ada") + + // Then — identity stitched from the embedded `user` block. + XCTAssertEqual(profile.id, "user-ada") + XCTAssertEqual(profile.username, "ada") + XCTAssertEqual(profile.displayName, "Ada Lovelace") + XCTAssertEqual(profile.avatarURL?.absoluteString, "https://cdn.interlinedlist.com/ada.png") + + // And — request shape: tiny page (limit 1, offset 0), no full feed pull. + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/user/ada/messages") + XCTAssertEqual(recorded.first?.query["limit"], "1") + XCTAssertEqual(recorded.first?.query["offset"], "0") + } + + func test_givenUsernameWithMessages_whenLoadingProfile_thenRicherFieldsAreNilForM1() async throws { + // Given — happy path again, but asserting the M1 limitation is + // encoded as a test rather than a hidden assumption: the fallback + // cannot populate bio/counts/joinedAt, so they must be `nil`. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedMessages(ids: ["m-1"])) + let service = SocialService(api: api) + + // When + let profile = try await service.profile(username: "ada") + + // Then + XCTAssertNil(profile.bio) + XCTAssertNil(profile.followerCount) + XCTAssertNil(profile.followingCount) + XCTAssertNil(profile.joinedAt) + XCTAssertFalse(profile.isPrivate) + } + + func test_givenUsernameWithNoMessages_whenLoadingProfile_thenThrowsProfileUnavailable() async throws { + // Given — boundary / empty path: zero public messages means no + // embedded author to project from. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedMessages(ids: [])) + let service = SocialService(api: api) + + // When / Then + do { + _ = try await service.profile(username: "ghost") + XCTFail("Expected SocialError.profileUnavailable") + } catch let error as SocialError { + XCTAssertEqual(error, .profileUnavailable(username: "ghost")) + } + } + + func test_givenAPIReturns404_whenLoadingProfile_thenThrowsAPIError() async throws { + // Given — upstream API failure: username does not exist. + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "user not found")) + let service = SocialService(api: api) + + // When / Then + do { + _ = try await service.profile(username: "nobody") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "user not found")) + } + } + + func test_givenAPIReturnsMalformedPayload_whenLoadingProfile_thenThrowsDecoding() async throws { + // Given — invalid input case: response missing the `messages` + // collection key. `PaginatedDecoder` surfaces this as `.decoding`. + let api = StubAPIClient() + await api.enqueue(json: "{ \"oops\": [] }") + let service = SocialService(api: api) + + // When / Then + do { + _ = try await service.profile(username: "ada") + XCTFail("Expected an APIError.decoding") + } catch let error as APIError { + if case .decoding = error { + // pass — exact decoder message is not part of the contract. + } else { + XCTFail("Expected .decoding, got \(error)") + } + } + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift index 36f1d53..f3257a1 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/Support/Fixtures.swift @@ -85,6 +85,154 @@ enum Fixtures { """ } + // MARK: - Lists fixtures + + /// A single `ListDTO` object body (the inner JSON of one list). + static func listObject( + id: String, + title: String = "Books", + description: String? = "Things I have read", + isPublic: Bool? = true, + schema: String? = "Title:text, Year:number", + parentId: String? = nil + ) -> String { + let descJSON = description.map { "\"\($0)\"" } ?? "null" + let schemaJSON = schema.map { "\"\($0)\"" } ?? "null" + let parentJSON = parentId.map { "\"\($0)\"" } ?? "null" + let isPublicJSON = isPublic.map { $0 ? "true" : "false" } ?? "null" + return """ + { + "id": "\(id)", + "title": "\(title)", + "description": \(descJSON), + "isPublic": \(isPublicJSON), + "schema": \(schemaJSON), + "parentId": \(parentJSON), + "createdAt": "\(createdAtISO)", + "updatedAt": "\(createdAtISO)" + } + """ + } + + /// The `{ "data": [...], "pagination": {...} }` envelope used by the + /// public-list browse and the row endpoint. + static func paginatedLists( + ids: [String], + total: Int? = nil, + limit: Int = 20, + offset: Int = 0, + hasMore: Bool = false + ) -> String { + let objects = ids.map { listObject(id: $0) }.joined(separator: ",") + let totalValue = total ?? ids.count + return """ + { + "data": [\(objects)], + "pagination": { + "total": \(totalValue), + "limit": \(limit), + "offset": \(offset), + "hasMore": \(hasMore) + } + } + """ + } + + /// A single `ListRowDTO` object body with two columns matching the schema + /// fixture. + static func listRowObject( + id: String, + listId: String = "list-1", + title: String = "Dune", + year: Int = 1965 + ) -> String { + """ + { + "id": "\(id)", + "listId": "\(listId)", + "rowData": { + "Title": "\(title)", + "Year": \(year) + }, + "createdAt": "\(createdAtISO)", + "updatedAt": "\(createdAtISO)" + } + """ + } + + /// The paginated row envelope: `{ "data": [...], "pagination": {...} }`. + static func paginatedRows( + ids: [String], + total: Int? = nil, + limit: Int = 20, + offset: Int = 0, + hasMore: Bool = false + ) -> String { + let objects = ids.map { listRowObject(id: $0) }.joined(separator: ",") + let totalValue = total ?? ids.count + return """ + { + "data": [\(objects)], + "pagination": { + "total": \(totalValue), + "limit": \(limit), + "offset": \(offset), + "hasMore": \(hasMore) + } + } + """ + } + + // MARK: - Follow / social fixtures + + /// `GET /api/follow/[userId]/status` shape. + static func followStatus( + following: Bool = false, + followedBy: Bool = false, + pendingRequest: Bool = false + ) -> String { + """ + { + "following": \(following), + "followedBy": \(followedBy), + "pendingRequest": \(pendingRequest) + } + """ + } + + /// `GET /api/follow/[userId]/counts` shape. + static func followCounts(followerCount: Int, followingCount: Int) -> String { + """ + { "followerCount": \(followerCount), "followingCount": \(followingCount) } + """ + } + + /// A single `FollowUserDTO` object body. + static func followUserObject( + id: String, + username: String = "ada", + displayName: String? = "Ada Lovelace", + avatarUrl: String? = "https://cdn.interlinedlist.com/ada.png" + ) -> String { + let displayJSON = displayName.map { "\"\($0)\"" } ?? "null" + let avatarJSON = avatarUrl.map { "\"\($0)\"" } ?? "null" + return """ + { + "id": "\(id)", + "username": "\(username)", + "displayName": \(displayJSON), + "avatarUrl": \(avatarJSON) + } + """ + } + + /// The bare-array shape `GET /api/follow/[userId]/followers` and `/following` + /// return today. + static func followUserArray(ids: [String]) -> String { + let objects = ids.map { followUserObject(id: $0) }.joined(separator: ",") + return "[\(objects)]" + } + /// The `GET /api/user` envelope: `{ "user": { ... } }`. static func userEnvelope( id: String = "user-ada", diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/InterlinedPersistence.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/InterlinedPersistence.swift index 3acd10d..4d20e3d 100644 --- a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/InterlinedPersistence.swift +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/InterlinedPersistence.swift @@ -1,15 +1,28 @@ // InterlinedPersistence // -// SwiftData schemas, cache policies, and the DocumentSyncEngine live here -// once Wave 4 lands (PLAN.md §6 M4). For M0 this is a placeholder namespace -// that proves the package builds and links against InterlinedDomain. +// SwiftData-backed caches and (later, in Wave 4) the DocumentSyncEngine +// (PLAN.md §3, §5 — timeline reads from a SwiftData cache with +// stale-while-revalidate). M1 ships only the message / timeline cache; the +// document, list, and folder schemas land with their respective milestones +// (M3 lists, M4 documents) rather than being half-implemented here. +// +// Schema/ — SwiftData @Model record types (MessageRecord, TimelinePageRecord). +// Stores/ — SwiftDataMessageStore: actor-isolated conformance to the +// InterlinedDomain.MessageStore port. +// Mapping/ — internal record <-> domain value-type translation. +// +// The package's public API surface is intentionally narrow: callers wire up a +// `SwiftDataMessageStore` and pass it to `MessagesService` as a `MessageStore`. import Foundation import InterlinedDomain -/// Namespace marker for InterlinedPersistence. +/// Namespace marker for InterlinedPersistence — used so callers have a +/// stable, discoverable type even when they only import the store from the +/// module's submodules. public enum InterlinedPersistence { - /// References the domain layer's kit version so all three packages - /// agree on the schema baseline during M0. - public static let domainKitSchemaVersion: String = InterlinedDomain.kitSchemaVersion + /// The kit/domain version this persistence layer was built against. Surfaced + /// so accidental local-package version skew shows up at a glance — same + /// pattern as `InterlinedDomain.builtAgainstKitVersion`. + public static let builtAgainstDomainVersion: String = InterlinedDomain.builtAgainstKitVersion } diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift new file mode 100644 index 0000000..f6c9c3b --- /dev/null +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Mapping/MessageRecordMapping.swift @@ -0,0 +1,106 @@ +import Foundation +import InterlinedDomain + +/// Internal mapping between SwiftData `MessageRecord` and the domain +/// `Message` value type. Kept package-internal so the public API surface +/// stays narrow: callers consume `Message` values across the actor boundary +/// and never see the `@Model` types directly (which is critical under Swift +/// 6 strict concurrency — `@Model` instances are not `Sendable`). + +extension MessageRecord { + + /// Build a new record from a domain `Message`. The recursive repost + /// target collapses to `pushedMessageID`; the store separately upserts + /// the original message so a later read can re-hydrate `Repost`. + convenience init(from message: Message) { + self.init( + id: message.id, + authorID: message.author.id, + authorUsername: message.author.username, + authorDisplayName: message.author.displayName, + authorAvatarURLString: message.author.avatarURL?.absoluteString, + text: message.text, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + tags: message.tags, + publiclyVisible: message.visibility.isPubliclyVisible, + digCount: message.digCount, + didDig: message.didDig, + repostCount: message.repostCount, + replyCount: message.replyCount, + parentID: message.parentID, + pushedMessageID: message.repost?.original.id, + scheduledAt: message.scheduledAt + ) + } + + /// Copy fresh field values from a domain `Message` into an existing + /// managed record — the upsert path. Must touch every mutable field so + /// stale data never leaks through. + func apply(_ message: Message) { + // `id` is the primary key, so it stays. + authorID = message.author.id + authorUsername = message.author.username + authorDisplayName = message.author.displayName + authorAvatarURLString = message.author.avatarURL?.absoluteString + text = message.text + createdAt = message.createdAt + updatedAt = message.updatedAt + tags = message.tags + publiclyVisible = message.visibility.isPubliclyVisible + digCount = message.digCount + didDig = message.didDig + repostCount = message.repostCount + replyCount = message.replyCount + parentID = message.parentID + pushedMessageID = message.repost?.original.id + scheduledAt = message.scheduledAt + } + + /// Hydrate the denormalized author back into a `UserSummary`. + var authorSummary: UserSummary { + UserSummary( + id: authorID, + username: authorUsername, + displayName: authorDisplayName, + avatarURL: authorAvatarURLString.flatMap(URL.init(string:)) + ) + } + + /// Hydrate a domain `Message` from this record. `repostLookup` is given + /// the `pushedMessageID` and may return the cached original; if it + /// returns `nil`, the repost target is dropped (best-effort cache). + func toMessage(repostLookup: (String) -> Message?) -> Message { + let repost: Repost? = pushedMessageID.flatMap { id in + repostLookup(id).map { .message($0) } + } + return Message( + id: id, + author: authorSummary, + text: text, + createdAt: createdAt, + updatedAt: updatedAt, + tags: tags, + visibility: Visibility(publiclyVisible: publiclyVisible), + digCount: digCount, + didDig: didDig, + repostCount: repostCount, + replyCount: replyCount, + parentID: parentID, + repost: repost, + scheduledAt: scheduledAt + ) + } +} + +extension TimelineScope { + /// Stable string form for the SwiftData composite key. Switching on the + /// case rather than using `rawValue` keeps the enum free of a wire + /// representation in the domain layer. + var rawScopeKey: String { + switch self { + case .all: return "all" + case .mine: return "mine" + } + } +} diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/MessageRecord.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/MessageRecord.swift new file mode 100644 index 0000000..f94ecb5 --- /dev/null +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/MessageRecord.swift @@ -0,0 +1,93 @@ +import Foundation +import SwiftData + +/// SwiftData record for a cached `Message` (PLAN.md §5 — timeline cache for +/// stale-while-revalidate). Fields mirror the round-trip surface of +/// `InterlinedDomain.Message` minus the recursively-nested repost target, +/// which is carried by `pushedMessageID` and re-hydrated by looking up the +/// referenced record at read time. +/// +/// The author identity (`UserSummary`) is stored denormalized on the record so +/// the timeline can render on first paint without a join — matches the goal in +/// the M1 task brief. +/// +/// Internal to the package: `MessageStore` consumers see only `Message` +/// values across the actor boundary. +@Model +final class MessageRecord { + /// Primary key — message id from the API. SwiftData lacks declarative + /// uniqueness in v1, so the store enforces upsert semantics by + /// fetching-then-mutating before inserting (`mergeUpsert`). + @Attribute(.unique) var id: String + + // Denormalized author identity — every field UserSummary needs. + var authorID: String + var authorUsername: String + var authorDisplayName: String + /// Stored as a string because SwiftData on macOS 14 handles URL + /// optionals awkwardly under strict concurrency; mapped back to `URL?` + /// on read. + var authorAvatarURLString: String? + + var text: String + var createdAt: Date + var updatedAt: Date + var tags: [String] + /// Wire-shaped boolean (matches `MessageDTO.publiclyVisible`); mapped to + /// `Visibility` at the boundary. + var publiclyVisible: Bool + + var digCount: Int + var didDig: Bool + var repostCount: Int + /// Optional — the timeline endpoint omits it; the replies endpoint + /// supplies it. Distinguishing "no replies" (0) from "unknown" (nil) + /// matters to the UI, so we round-trip the optional. + var replyCount: Int? + + var parentID: String? + /// The id of the original message this one reposts ("pushes"). The + /// store re-hydrates `Repost.message(...)` by looking up the referenced + /// record on read — best-effort, dropped silently if not in cache. + var pushedMessageID: String? + + var scheduledAt: Date? + + init( + id: String, + authorID: String, + authorUsername: String, + authorDisplayName: String, + authorAvatarURLString: String?, + text: String, + createdAt: Date, + updatedAt: Date, + tags: [String], + publiclyVisible: Bool, + digCount: Int, + didDig: Bool, + repostCount: Int, + replyCount: Int?, + parentID: String?, + pushedMessageID: String?, + scheduledAt: Date? + ) { + self.id = id + self.authorID = authorID + self.authorUsername = authorUsername + self.authorDisplayName = authorDisplayName + self.authorAvatarURLString = authorAvatarURLString + self.text = text + self.createdAt = createdAt + self.updatedAt = updatedAt + self.tags = tags + self.publiclyVisible = publiclyVisible + self.digCount = digCount + self.didDig = didDig + self.repostCount = repostCount + self.replyCount = replyCount + self.parentID = parentID + self.pushedMessageID = pushedMessageID + self.scheduledAt = scheduledAt + } +} diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/TimelinePageRecord.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/TimelinePageRecord.swift new file mode 100644 index 0000000..1e861ec --- /dev/null +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Schema/TimelinePageRecord.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftData + +/// SwiftData record for a cached timeline slice (PLAN.md §5). +/// +/// Keyed by `(scopeRaw, tag)` — the same composite key the in-memory store +/// uses. Holds an ordered list of message ids so `cachedTimeline(scope:tag:)` +/// is a single keyed lookup that hydrates messages from `MessageRecord`s by +/// id. `fetchedAt` is recorded for future stale-while-revalidate policy +/// (not yet consumed by the M1 store). +/// +/// Internal to the package. +@Model +final class TimelinePageRecord { + /// `TimelineScope.rawScopeKey` — a stable string form of the enum case. + /// Kept as a `String` rather than the enum itself because SwiftData + /// macros on macOS 14 do not play well with non-Codable raw-value + /// enums under strict concurrency. + var scopeRaw: String + /// Optional tag filter (`nil` means "no tag filter"). Combined with + /// `scopeRaw` to identify a distinct cached feed. + var tag: String? + /// Ordered ids of the cached messages, in timeline order. + var messageIDs: [String] + /// When this slice was last replaced. Reserved for future + /// stale-while-revalidate decisions. + var fetchedAt: Date + + init(scopeRaw: String, tag: String?, messageIDs: [String], fetchedAt: Date) { + self.scopeRaw = scopeRaw + self.tag = tag + self.messageIDs = messageIDs + self.fetchedAt = fetchedAt + } +} diff --git a/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataMessageStore.swift b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataMessageStore.swift new file mode 100644 index 0000000..51966fd --- /dev/null +++ b/Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataMessageStore.swift @@ -0,0 +1,229 @@ +import Foundation +import SwiftData +import os +import InterlinedDomain + +/// SwiftData-backed `MessageStore` (PLAN.md §3, §5). Conforms to the cache +/// port in `InterlinedDomain` so `MessagesService` can be wired up with a +/// durable cache in the app and an `InMemoryMessageStore` in tests. +/// +/// Implemented as an `actor` so: +/// - `ModelContext` (which is not thread-safe and not `Sendable`) is +/// confined to a single isolation domain; +/// - the protocol's non-throwing async methods compose cleanly under Swift +/// 6 strict concurrency; +/// - mutable internal state needs no manual locking. +/// +/// Only `Sendable` value types (`Message`, `[Message]`, `Message?`) cross +/// the actor boundary. `@Model` records never escape. +/// +/// All SwiftData throws are caught and logged via `os.Logger` — per the +/// `MessageStore` contract the cache is best-effort and must never break +/// a live fetch (see `MessageStore.swift` docs). +public actor SwiftDataMessageStore: MessageStore { + + private let container: ModelContainer + /// Lazily created on first use so the actor owns sole reference to its + /// context — `ModelContext` is not `Sendable`, so we keep it strictly + /// actor-isolated. + private var _context: ModelContext? + private let logger = Logger( + subsystem: "com.interlinedlist.macos.persistence", + category: "SwiftDataMessageStore" + ) + + /// Designated initializer. The caller owns the `ModelContainer` so the + /// app can share one container across multiple stores or screens. + public init(container: ModelContainer) { + self.container = container + } + + /// Convenience factory that returns a fully in-memory store, intended + /// for tests and previews. Leaves no disk artifacts. + /// + /// Throws if `ModelContainer` cannot be constructed — which on a + /// well-formed schema would only happen for runtime reasons (sandbox, + /// memory). Tests that hit this should fail loudly rather than + /// silently degrade, hence the throwing factory. + public static func inMemory() throws -> SwiftDataMessageStore { + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer( + for: MessageRecord.self, TimelinePageRecord.self, + configurations: configuration + ) + return SwiftDataMessageStore(container: container) + } + + // MARK: - MessageStore + + public func cachedTimeline(scope: TimelineScope, tag: String?) async -> [Message] { + let context = self.context + let scopeKey = scope.rawScopeKey + let ids: [String] + do { + let descriptor = timelinePageFetchDescriptor(scopeKey: scopeKey, tag: tag) + let page = try context.fetch(descriptor).first + ids = page?.messageIDs ?? [] + } catch { + logger.error("cachedTimeline fetch failed: \(error.localizedDescription, privacy: .public)") + return [] + } + + guard !ids.isEmpty else { return [] } + + // Hydrate by id, preserving order. A single fetch over the id-set + // is cheaper than N point fetches, then we reorder in memory. + let records = fetchRecords(byIDs: ids, context: context) + let recordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) + return ids.compactMap { id in + recordsByID[id]?.toMessage { originalID in + // Cheap repost re-hydration: look up the original message in + // the same fetched batch first, then fall back to the by-id + // index for off-page reposts. + recordsByID[originalID]?.toMessage(repostLookup: { _ in nil }) + ?? self.byIDMessage(id: originalID, context: context) + } + } + } + + public func replaceTimeline(_ messages: [Message], scope: TimelineScope, tag: String?) async { + let context = self.context + let scopeKey = scope.rawScopeKey + + // 1) Upsert message records so by-id reads stay consistent with the + // timeline slice (matches InMemoryMessageStore.replaceTimeline, + // which writes to both indexes). + mergeUpsert(messages, context: context) + + // 2) Replace the page record for this (scope, tag) key. + do { + let descriptor = timelinePageFetchDescriptor(scopeKey: scopeKey, tag: tag) + let existing = try context.fetch(descriptor) + for page in existing { context.delete(page) } + let fresh = TimelinePageRecord( + scopeRaw: scopeKey, + tag: tag, + messageIDs: messages.map(\.id), + fetchedAt: Date() + ) + context.insert(fresh) + try context.save() + } catch { + logger.error("replaceTimeline save failed: \(error.localizedDescription, privacy: .public)") + } + } + + public func cachedMessage(id: String) async -> Message? { + let context = self.context + return byIDMessage(id: id, context: context) + } + + public func upsert(_ messages: [Message]) async { + let context = self.context + mergeUpsert(messages, context: context) + do { + try context.save() + } catch { + logger.error("upsert save failed: \(error.localizedDescription, privacy: .public)") + } + } + + public func clear() async { + let context = self.context + do { + try context.delete(model: TimelinePageRecord.self) + try context.delete(model: MessageRecord.self) + try context.save() + } catch { + logger.error("clear failed: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Internals + + /// Lazy `ModelContext` accessor. Created once per actor instance and + /// reused; staying actor-isolated means we never need a `MainActor` + /// context here. + private var context: ModelContext { + if let existing = _context { return existing } + let fresh = ModelContext(container) + _context = fresh + return fresh + } + + private func timelinePageFetchDescriptor( + scopeKey: String, + tag: String? + ) -> FetchDescriptor { + // SwiftData predicates on macOS 14 do not always optimize an + // optional-equality cleanly; splitting on `tag == nil` avoids the + // edge case and produces a simpler predicate either way. + if let tag { + return FetchDescriptor( + predicate: #Predicate { record in + record.scopeRaw == scopeKey && record.tag == tag + } + ) + } else { + return FetchDescriptor( + predicate: #Predicate { record in + record.scopeRaw == scopeKey && record.tag == nil + } + ) + } + } + + private func fetchRecords(byIDs ids: [String], context: ModelContext) -> [MessageRecord] { + guard !ids.isEmpty else { return [] } + let idSet = Set(ids) + do { + let descriptor = FetchDescriptor( + predicate: #Predicate { record in + idSet.contains(record.id) + } + ) + return try context.fetch(descriptor) + } catch { + logger.error("fetchRecords failed: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + private func byIDMessage(id: String, context: ModelContext) -> Message? { + do { + let descriptor = FetchDescriptor( + predicate: #Predicate { record in record.id == id } + ) + guard let record = try context.fetch(descriptor).first else { return nil } + return record.toMessage { originalID in + // Single-hop lookup for the repost target. If it isn't in + // the cache we drop it — best-effort per the protocol. + self.byIDMessage(id: originalID, context: context).map { $0 } + } + } catch { + logger.error("cachedMessage fetch failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// Insert-or-update by id, without saving. Caller decides when to + /// `save()`. SwiftData on macOS 14 lacks a declarative upsert, so we + /// fetch by id and mutate when present. + private func mergeUpsert(_ messages: [Message], context: ModelContext) { + for message in messages { + let id = message.id + do { + let descriptor = FetchDescriptor( + predicate: #Predicate { record in record.id == id } + ) + if let existing = try context.fetch(descriptor).first { + existing.apply(message) + } else { + context.insert(MessageRecord(from: message)) + } + } catch { + logger.error("mergeUpsert failed for id \(id, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/InterlinedPersistenceTests.swift b/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/InterlinedPersistenceTests.swift index 7a1f066..20821a1 100644 --- a/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/InterlinedPersistenceTests.swift +++ b/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/InterlinedPersistenceTests.swift @@ -2,15 +2,10 @@ import XCTest @testable import InterlinedPersistence final class InterlinedPersistenceTests: XCTestCase { - func test_givenPersistenceNamespace_whenSchemaVersionRead_thenMatchesDomainConstant() { - // Given / When - let version = InterlinedPersistence.domainKitSchemaVersion - - // Then - XCTAssertEqual(version, "0.0.1-M0") - } - - func test_givenPersistenceNamespace_whenSchemaVersionRead_thenIsNotEmpty() { - XCTAssertFalse(InterlinedPersistence.domainKitSchemaVersion.isEmpty) + func test_givenPersistenceNamespace_whenBuiltAgainstDomainVersionRead_thenIsNotEmpty() { + // Surfaces the cross-package version pin so accidental local-package + // skew is visible at a glance (PLAN.md §3 — three packages, one + // schema baseline). + XCTAssertFalse(InterlinedPersistence.builtAgainstDomainVersion.isEmpty) } } diff --git a/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataMessageStoreTests.swift b/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataMessageStoreTests.swift new file mode 100644 index 0000000..964b74b --- /dev/null +++ b/Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataMessageStoreTests.swift @@ -0,0 +1,270 @@ +import XCTest +import InterlinedDomain +@testable import InterlinedPersistence + +final class SwiftDataMessageStoreTests: XCTestCase { + + // MARK: - Timeline cache + + func test_givenReplacedTimeline_whenReadingSameKey_thenReturnsMessagesInOrder() async throws { + // Given + let store = try SwiftDataMessageStore.inMemory() + let messages = [ + sampleMessage(id: "a", text: "first"), + sampleMessage(id: "b", text: "second"), + sampleMessage(id: "c", text: "third") + ] + + // When + await store.replaceTimeline(messages, scope: .all, tag: nil) + + // Then + let cached = await store.cachedTimeline(scope: .all, tag: nil) + XCTAssertEqual(cached.map(\.id), ["a", "b", "c"]) + XCTAssertEqual(cached.map(\.text), ["first", "second", "third"]) + } + + func test_givenTwoDistinctTimelineKeys_whenReadingEach_thenIsolatedFromEachOther() async throws { + // Given + let store = try SwiftDataMessageStore.inMemory() + + // When + await store.replaceTimeline( + [sampleMessage(id: "a")], + scope: .all, + tag: nil + ) + await store.replaceTimeline( + [sampleMessage(id: "b")], + scope: .mine, + tag: "swift" + ) + + // Then — each key returns its own slice, no cross-pollination. + let all = await store.cachedTimeline(scope: .all, tag: nil) + let mineSwift = await store.cachedTimeline(scope: .mine, tag: "swift") + let mineNoTag = await store.cachedTimeline(scope: .mine, tag: nil) + let allSwift = await store.cachedTimeline(scope: .all, tag: "swift") + + XCTAssertEqual(all.map(\.id), ["a"]) + XCTAssertEqual(mineSwift.map(\.id), ["b"]) + XCTAssertTrue(mineNoTag.isEmpty) + XCTAssertTrue(allSwift.isEmpty) + } + + func test_givenReplacedTimelineThenReplacedAgain_whenReadingKey_thenReturnsLatestOnly() async throws { + // Given — first page lands, then a fresh page replaces it. + let store = try SwiftDataMessageStore.inMemory() + await store.replaceTimeline( + [sampleMessage(id: "a"), sampleMessage(id: "b")], + scope: .all, + tag: nil + ) + + // When + await store.replaceTimeline( + [sampleMessage(id: "c")], + scope: .all, + tag: nil + ) + + // Then — the second replace fully supersedes the first. + let cached = await store.cachedTimeline(scope: .all, tag: nil) + XCTAssertEqual(cached.map(\.id), ["c"]) + } + + func test_givenEmptyStore_whenReadingTimeline_thenReturnsEmpty() async throws { + // Given + let store = try SwiftDataMessageStore.inMemory() + + // When + let cached = await store.cachedTimeline(scope: .all, tag: nil) + + // Then + XCTAssertTrue(cached.isEmpty) + } + + // MARK: - By-id upsert + + func test_givenUpsertedMessage_whenReadingByID_thenRoundTripsAllFields() async throws { + // Given + let store = try SwiftDataMessageStore.inMemory() + let original = sampleMessage( + id: "x", + text: "round-trip me", + tags: ["swift", "macos"], + visibility: .private, + parentID: "parent-1", + scheduledAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + + // When + await store.upsert([original]) + + // Then + let fetched = await store.cachedMessage(id: "x") + XCTAssertEqual(fetched, original) + } + + func test_givenUpsertedTwice_whenReadingByID_thenSecondWriteWins() async throws { + // Given + let store = try SwiftDataMessageStore.inMemory() + + // When + await store.upsert([sampleMessage(id: "a", text: "v1")]) + await store.upsert([sampleMessage(id: "a", text: "v2")]) + + // Then — update semantics, not duplicate. + let fetched = await store.cachedMessage(id: "a") + XCTAssertEqual(fetched?.text, "v2") + } + + func test_givenEmptyStore_whenReadingMessageByID_thenReturnsNil() async throws { + // Given + let store = try SwiftDataMessageStore.inMemory() + + // When + let fetched = await store.cachedMessage(id: "nope") + + // Then + XCTAssertNil(fetched) + } + + func test_givenReplacedTimeline_whenReadingMessageByID_thenAlsoIndexed() async throws { + // Given — matches InMemoryMessageStore semantics: replaceTimeline + // also populates the by-id index. + let store = try SwiftDataMessageStore.inMemory() + + // When + await store.replaceTimeline([sampleMessage(id: "a")], scope: .all, tag: nil) + + // Then + let byID = await store.cachedMessage(id: "a") + XCTAssertEqual(byID?.id, "a") + } + + // MARK: - Clear + + func test_givenPopulatedStore_whenCleared_thenTimelineAndByIDCachesBothEmpty() async throws { + // Given + let store = try SwiftDataMessageStore.inMemory() + await store.replaceTimeline( + [sampleMessage(id: "a"), sampleMessage(id: "b")], + scope: .all, + tag: nil + ) + await store.upsert([sampleMessage(id: "c")]) + + // When + await store.clear() + + // Then — both indexes are empty. + let timeline = await store.cachedTimeline(scope: .all, tag: nil) + let a = await store.cachedMessage(id: "a") + let b = await store.cachedMessage(id: "b") + let c = await store.cachedMessage(id: "c") + XCTAssertTrue(timeline.isEmpty) + XCTAssertNil(a) + XCTAssertNil(b) + XCTAssertNil(c) + } + + // MARK: - Repost re-hydration + + func test_givenRepostedMessageInCache_whenReadingReposter_thenRepostHydrated() async throws { + // Given — the original is cached, and the reposter references it. + let store = try SwiftDataMessageStore.inMemory() + let original = sampleMessage(id: "orig", text: "the original") + let reposter = sampleMessage( + id: "repost", + text: "look at this", + repost: .message(original) + ) + + // When + await store.upsert([original, reposter]) + + // Then — the repost target re-hydrates from the by-id cache. + let fetched = await store.cachedMessage(id: "repost") + XCTAssertEqual(fetched?.repost?.original.id, "orig") + XCTAssertEqual(fetched?.repost?.original.text, "the original") + } + + func test_givenRepostedMessageMissingFromCache_whenReadingReposter_thenRepostDroppedSilently() async throws { + // Given — only the reposter is cached; the original is not. The + // store treats the cache as best-effort, so the repost reference + // should silently drop rather than throw. + let store = try SwiftDataMessageStore.inMemory() + let original = sampleMessage(id: "ghost", text: "not cached") + let reposter = sampleMessage( + id: "repost", + text: "look at this", + repost: .message(original) + ) + + // When + await store.upsert([reposter]) + + // Then + let fetched = await store.cachedMessage(id: "repost") + XCTAssertNotNil(fetched) + XCTAssertNil(fetched?.repost) + } + + // MARK: - Concurrency sanity + + func test_givenConcurrentUpsertsOfDifferentIDs_whenAllComplete_thenAllReadable() async throws { + // Given — actor isolation should serialize SwiftData mutations, + // even when two Tasks fire upserts at the same time. Build the + // Sendable payloads up front so `async let` only crosses the + // isolation boundary with `Message` values, not `self`. + let store = try SwiftDataMessageStore.inMemory() + let first = sampleMessage(id: "task-1", text: "one") + let second = sampleMessage(id: "task-2", text: "two") + + // When + async let one: Void = store.upsert([first]) + async let two: Void = store.upsert([second]) + _ = await (one, two) + + // Then — both writes survived, neither crashed the actor. + let firstFetched = await store.cachedMessage(id: "task-1") + let secondFetched = await store.cachedMessage(id: "task-2") + XCTAssertEqual(firstFetched?.text, "one") + XCTAssertEqual(secondFetched?.text, "two") + } + + // MARK: - Helpers + + private func sampleMessage( + id: String, + text: String = "hi", + tags: [String] = [], + visibility: Visibility = .public, + parentID: String? = nil, + repost: Repost? = nil, + scheduledAt: Date? = nil + ) -> Message { + Message( + id: id, + author: UserSummary( + id: "u1", + username: "ada", + displayName: "Ada", + avatarURL: URL(string: "https://example.test/ada.png") + ), + text: text, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + tags: tags, + visibility: visibility, + digCount: 0, + didDig: false, + repostCount: 0, + replyCount: nil, + parentID: parentID, + repost: repost, + scheduledAt: scheduledAt + ) + } +} diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 1943560..bb69b63 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -7,121 +7,133 @@ This matrix exists so that full coverage of the [InterlinedList API](https://int **Maintenance rule:** the documentation engineer updates this matrix at the end of each wave, after the wave gate passes. A row's **Implemented** box is checked only when the endpoint's request builder, DTOs, and service call path are merged; **Tested** is checked only when BDD-named unit tests against `APIClient` stubs cover that endpoint (happy path, invalid input, API failure, empty/boundary — PLAN.md §7). No box is checked speculatively. - Source of truth for the endpoint inventory: https://interlinedlist.com/help/api (verified 2026-06-11), cross-checked against PLAN.md §1. -- ☐ = not done, ☑ = done. All rows start unchecked. +- ☐ = not done, ☑ = done, ◐ = **partial** (builder + DTO + service path merged and at least one behavior test exists, but not all four of happy/invalid/failure/empty are present yet — see footnote 4). All rows start unchecked. - **Auth** column reproduces the API reference's annotation. Groups marked *Session* are subject to the M0 Bearer-vs-Session spike (`docs/spikes/auth-bearer-vs-session.md`, decision in `docs/decisions/0001-auth-transport.md`). - The three `GET /api/users/[username]/lists*` endpoints appear in the API reference under both **Lists** and **Public**; they are listed once here, under **Lists**, with no-auth noted. | Endpoint (method + path) | Group | Auth | Planned service | Milestone | Implemented | Tested | | --- | --- | --- | --- | --- | --- | --- | -| `POST /api/auth/login` | Auth | Public → session cookie | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | -| `POST /api/auth/logout` | Auth | Session | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | -| `POST /api/auth/register` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | -| `POST /api/auth/sync-token` | Auth | Public → Bearer token | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | -| `POST /api/auth/forgot-password` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | -| `POST /api/auth/reset-password` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | -| `POST /api/auth/send-verification-email` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | -| `POST /api/auth/verify-email` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☐ | ☐ | +| `POST /api/auth/login` | Auth | Public → session cookie | AuthService (InterlinedKit/Auth) | M0 | ☐⁵ | ☐ | +| `POST /api/auth/logout` | Auth | Session | AuthService (InterlinedKit/Auth) | M0 | ☑ | ◐⁴ | +| `POST /api/auth/register` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☑ | ☐⁶ | +| `POST /api/auth/sync-token` | Auth | Public → Bearer token | AuthService (InterlinedKit/Auth) | M0 | ☑ | ◐⁴ | +| `POST /api/auth/forgot-password` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☑ | ◐⁴ | +| `POST /api/auth/reset-password` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☑ | ◐⁴ | +| `POST /api/auth/send-verification-email` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☑ | ◐⁴ | +| `POST /api/auth/verify-email` | Auth | Public | AuthService (InterlinedKit/Auth) | M0 | ☑ | ◐⁴ | | `GET /api/auth/github/authorize` | Auth (OAuth) | Public | AuthService (OAuth flows) | M6 | ☐ | ☐ | | `GET /api/auth/mastodon/authorize` | Auth (OAuth) | Public | AuthService (OAuth flows) | M6 | ☐ | ☐ | | `GET /api/auth/bluesky/authorize` | Auth (OAuth) | Public | AuthService (OAuth flows) | M6 | ☐ | ☐ | | `GET /api/auth/linkedin/authorize` | Auth (OAuth) | Public | AuthService (OAuth flows) | M6 | ☐ | ☐ | -| `GET /api/user` | User | Session or Bearer | UserService¹ (+ EntitlementsService reads `customerStatus`) | M0 | ☐ | ☐ | -| `POST /api/user/update` | User | Session | UserService¹ | M7 | ☐ | ☐ | -| `POST /api/user/avatar/upload` | User | Session | UserService¹ | M7 | ☐ | ☐ | -| `POST /api/user/avatar/from-url` | User | Session | UserService¹ | M7 | ☐ | ☐ | -| `GET /api/user/identities` | User | Session | UserService¹ | M6 | ☐ | ☐ | -| `GET /api/user/organizations` | User | Session | OrgService | M6 | ☐ | ☐ | -| `POST /api/user/change-email/request` | User | Session | UserService¹ | M7 | ☐ | ☐ | -| `POST /api/user/delete` | User | Session | UserService¹ | M7 | ☐ | ☐ | -| `GET /api/messages` | Messages | Session or Bearer | MessagesService | M1 | ☐ | ☐ | -| `POST /api/messages` | Messages | Session or Bearer | MessagesService | M2² | ☐ | ☐ | -| `GET /api/messages/[id]` | Messages | Session or Bearer | MessagesService | M1 | ☐ | ☐ | -| `PUT /api/messages/[id]` | Messages | Session or Bearer | MessagesService | M2 | ☐ | ☐ | -| `DELETE /api/messages/[id]` | Messages | Session or Bearer | MessagesService | M2 | ☐ | ☐ | -| `GET /api/messages/scheduled` | Messages | Session or Bearer | MessagesService | M6 | ☐ | ☐ | -| `GET /api/messages/[id]/replies` | Messages | Session | MessagesService | M1 | ☐ | ☐ | -| `POST /api/messages/[id]/dig` | Messages | Session | MessagesService | M2 | ☐ | ☐ | -| `DELETE /api/messages/[id]/dig` | Messages | Session | MessagesService | M2 | ☐ | ☐ | -| `POST /api/messages/images/upload` | Messages | Session or Bearer | MessagesService | M6 | ☐ | ☐ | -| `POST /api/messages/videos/upload` | Messages | Session or Bearer | MessagesService | M6 | ☐ | ☐ | -| `GET /api/lists` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `POST /api/lists` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/lists/[id]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `PUT /api/lists/[id]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `DELETE /api/lists/[id]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/lists/[id]/schema` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `PUT /api/lists/[id]/schema` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `POST /api/lists/[id]/refresh` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/lists/[id]/data` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `POST /api/lists/[id]/data` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/lists/[id]/data/[rowId]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `PATCH /api/lists/[id]/data/[rowId]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `DELETE /api/lists/[id]/data/[rowId]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/lists/[id]/watchers` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/lists/[id]/watchers/me` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/lists/[id]/watchers/users` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `PUT /api/lists/[id]/watchers/[userId]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `DELETE /api/lists/[id]/watchers/[userId]` | Lists | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/users/[username]/lists` | Lists (public) | None | ListsService | M1 | ☐ | ☐ | -| `GET /api/users/[username]/lists/[id]` | Lists (public) | None | ListsService | M1 | ☐ | ☐ | -| `GET /api/users/[username]/lists/[id]/data` | Lists (public) | None | ListsService | M1 | ☐ | ☐ | -| `GET /api/lists/connections` | List Connections | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `POST /api/lists/connections` | List Connections | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `DELETE /api/lists/connections/[id]` | List Connections | Session or Bearer | ListsService | M3 | ☐ | ☐ | -| `GET /api/documents/sync` | Documents & Sync | Session or Bearer | DocumentSyncEngine (InterlinedPersistence) | M4 | ☐ | ☐ | -| `POST /api/documents/sync` | Documents & Sync | Session or Bearer | DocumentSyncEngine (InterlinedPersistence) | M4 | ☐ | ☐ | -| `GET /api/documents` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `POST /api/documents` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `GET /api/documents/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `PATCH /api/documents/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `DELETE /api/documents/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `POST /api/documents/[id]/images/upload` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `GET /api/documents/folders` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `POST /api/documents/folders` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `GET /api/documents/folders/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `PATCH /api/documents/folders/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `DELETE /api/documents/folders/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `GET /api/documents/folders/[id]/documents` | Documents & Sync | Session | DocumentsService | M4 | ☐ | ☐ | -| `POST /api/follow/[userId]` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `DELETE /api/follow/[userId]` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `GET /api/follow/[userId]/status` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `GET /api/follow/[userId]/followers` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `GET /api/follow/[userId]/following` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `GET /api/follow/[userId]/counts` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `GET /api/follow/[userId]/mutual` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `POST /api/follow/[userId]/approve` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `POST /api/follow/[userId]/reject` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `POST /api/follow/[userId]/remove` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `GET /api/follow/requests` | Follow | Session | SocialService | M5 | ☐ | ☐ | -| `GET /api/organizations` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `POST /api/organizations` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `GET /api/organizations/[id]` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `PATCH /api/organizations/[id]` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `GET /api/organizations/[id]/members` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `POST /api/organizations/[id]/members` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `PUT /api/organizations/[id]/members/[userId]` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `DELETE /api/organizations/[id]/members/[userId]` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `GET /api/organizations/[id]/users` | Organizations | Session | OrgService | M6 | ☐ | ☐ | -| `GET /api/exports/messages` | Exports | Session | ExportsService¹ | M7 | ☐ | ☐ | -| `GET /api/exports/lists` | Exports | Session | ExportsService¹ | M7 | ☐ | ☐ | -| `GET /api/exports/list-data-rows` | Exports | Session | ExportsService¹ | M7 | ☐ | ☐ | -| `GET /api/exports/follows` | Exports | Session | ExportsService¹ | M7 | ☐ | ☐ | -| `GET /api/notifications` | Notifications | Session | NotificationsService | M5 | ☐ | ☐ | -| `PATCH /api/notifications/[id]/read` | Notifications | Session | NotificationsService | M5 | ☐ | ☐ | -| `POST /api/notifications/mark-all-read` | Notifications | Session | NotificationsService | M5 | ☐ | ☐ | -| `GET /api/user/[username]/messages` | Public | None | MessagesService | M1 | ☐ | ☐ | +| `GET /api/user` | User | Session or Bearer | UserService¹ (+ EntitlementsService reads `customerStatus`) | M0 | ☑ | ☑ | +| `POST /api/user/update` | User | Session | UserService¹ | M7 | ☑ | ☑ | +| `POST /api/user/avatar/upload` | User | Session | UserService¹ | M7 | ☑ | ◐⁴ | +| `POST /api/user/avatar/from-url` | User | Session | UserService¹ | M7 | ☑ | ◐⁴ | +| `GET /api/user/identities` | User | Session | UserService¹ | M6 | ☑ | ◐⁴ | +| `GET /api/user/organizations` | User | Session | UserService¹ ⁷ | M6 | ☑ | ◐⁴ | +| `POST /api/user/change-email/request` | User | Session | UserService¹ | M7 | ☑ | ◐⁴ | +| `POST /api/user/delete` | User | Session | UserService¹ | M7 | ☑ | ◐⁴ | +| `GET /api/messages` | Messages | Session or Bearer | MessagesService | M1 | ☑ | ☑ | +| `POST /api/messages` | Messages | Session or Bearer | MessagesService | M2² | ☑ | ☑ | +| `GET /api/messages/[id]` | Messages | Session or Bearer | MessagesService | M1 | ☑ | ☑ | +| `PUT /api/messages/[id]` | Messages | Session or Bearer | MessagesService | M2 | ☑ | ◐⁴ | +| `DELETE /api/messages/[id]` | Messages | Session or Bearer | MessagesService | M2 | ☑ | ◐⁴ | +| `GET /api/messages/scheduled` | Messages | Session or Bearer | MessagesService | M6 | ☑ | ☑ | +| `GET /api/messages/[id]/replies` | Messages | Session | MessagesService | M1 | ☑ | ☑ | +| `POST /api/messages/[id]/dig` | Messages | Session | MessagesService | M2 | ☑ | ◐⁴ | +| `DELETE /api/messages/[id]/dig` | Messages | Session | MessagesService | M2 | ☑ | ◐⁴ | +| `POST /api/messages/images/upload` | Messages | Session or Bearer | MessagesService | M6 | ☑ | ◐⁴ | +| `POST /api/messages/videos/upload` | Messages | Session or Bearer | MessagesService | M6 | ☑ | ◐⁴ | +| `GET /api/lists` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `POST /api/lists` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/lists/[id]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `PUT /api/lists/[id]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `DELETE /api/lists/[id]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/lists/[id]/schema` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `PUT /api/lists/[id]/schema` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `POST /api/lists/[id]/refresh` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/lists/[id]/data` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `POST /api/lists/[id]/data` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/lists/[id]/data/[rowId]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `PATCH /api/lists/[id]/data/[rowId]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `DELETE /api/lists/[id]/data/[rowId]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/lists/[id]/watchers` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/lists/[id]/watchers/me` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/lists/[id]/watchers/users` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `PUT /api/lists/[id]/watchers/[userId]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `DELETE /api/lists/[id]/watchers/[userId]` | Lists | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/users/[username]/lists` | Lists (public) | None | ListsService | M1 | ☑ | ☑ | +| `GET /api/users/[username]/lists/[id]` | Lists (public) | None | ListsService | M1 | ☑ | ☑ | +| `GET /api/users/[username]/lists/[id]/data` | Lists (public) | None | ListsService | M1 | ☑ | ☑ | +| `GET /api/lists/connections` | List Connections | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `POST /api/lists/connections` | List Connections | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `DELETE /api/lists/connections/[id]` | List Connections | Session or Bearer | ListsService | M3 | ☑ | ◐⁴ | +| `GET /api/documents/sync` | Documents & Sync | Session or Bearer | DocumentSyncEngine (InterlinedPersistence) | M4 | ☑ | ◐⁴ | +| `POST /api/documents/sync` | Documents & Sync | Session or Bearer | DocumentSyncEngine (InterlinedPersistence) | M4 | ☑ | ◐⁴ | +| `GET /api/documents` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `POST /api/documents` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `GET /api/documents/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `PATCH /api/documents/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `DELETE /api/documents/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `POST /api/documents/[id]/images/upload` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `GET /api/documents/folders` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `POST /api/documents/folders` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `GET /api/documents/folders/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `PATCH /api/documents/folders/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `DELETE /api/documents/folders/[id]` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `GET /api/documents/folders/[id]/documents` | Documents & Sync | Session | DocumentsService | M4 | ☑ | ◐⁴ | +| `POST /api/follow/[userId]` | Follow | Session | SocialService | M5 | ☑ | ◐⁴ | +| `DELETE /api/follow/[userId]` | Follow | Session | SocialService | M5 | ☑ | ◐⁴ | +| `GET /api/follow/[userId]/status` | Follow | Session | SocialService | M5 | ☑ | ☑ | +| `GET /api/follow/[userId]/followers` | Follow | Session | SocialService | M5 | ☑ | ☑ | +| `GET /api/follow/[userId]/following` | Follow | Session | SocialService | M5 | ☑ | ☑ | +| `GET /api/follow/[userId]/counts` | Follow | Session | SocialService | M5 | ☑ | ☑ | +| `GET /api/follow/[userId]/mutual` | Follow | Session | SocialService | M5 | ☑ | ◐⁴ | +| `POST /api/follow/[userId]/approve` | Follow | Session | SocialService | M5 | ☑ | ◐⁴ | +| `POST /api/follow/[userId]/reject` | Follow | Session | SocialService | M5 | ☑ | ◐⁴ | +| `POST /api/follow/[userId]/remove` | Follow | Session | SocialService | M5 | ☑ | ◐⁴ | +| `GET /api/follow/requests` | Follow | Session | SocialService | M5 | ☑ | ◐⁴ | +| `GET /api/organizations` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `POST /api/organizations` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `GET /api/organizations/[id]` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `PATCH /api/organizations/[id]` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `GET /api/organizations/[id]/members` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `POST /api/organizations/[id]/members` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `PUT /api/organizations/[id]/members/[userId]` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `DELETE /api/organizations/[id]/members/[userId]` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `GET /api/organizations/[id]/users` | Organizations | Session | OrgService | M6 | ☑ | ◐⁴ | +| `GET /api/exports/messages` | Exports | Session | ExportsService¹ | M7 | ☑ | ◐⁴ | +| `GET /api/exports/lists` | Exports | Session | ExportsService¹ | M7 | ☑ | ◐⁴ | +| `GET /api/exports/list-data-rows` | Exports | Session | ExportsService¹ | M7 | ☑ | ◐⁴ | +| `GET /api/exports/follows` | Exports | Session | ExportsService¹ | M7 | ☑ | ◐⁴ | +| `GET /api/notifications` | Notifications | Session | NotificationsService | M5 | ☑ | ☑ | +| `PATCH /api/notifications/[id]/read` | Notifications | Session | NotificationsService | M5 | ☑ | ◐⁴ | +| `POST /api/notifications/mark-all-read` | Notifications | Session | NotificationsService | M5 | ☑ | ◐⁴ | +| `GET /api/user/[username]/messages` | Public | None | MessagesService⁸ | M1 | ☑ | ☑ | | `GET /api/auth/linkedin/status` | Public | None | AuthService (OAuth flows) | M6 | ☐ | ☐ | **Totals:** 98 endpoints — Auth 12 · User 8 · Messages 11 · Lists 21 (incl. 3 public) · List Connections 3 · Documents & Sync 14 · Follow 11 · Organizations 9 · Exports 4 · Notifications 3 · Public-only 2. ## Footnotes and assumptions -1. **UserService / ExportsService** are not explicitly named in PLAN.md §3 (its service list ends with an ellipsis: "MessagesService, ListsService, DocumentsService, SocialService, OrgService, NotificationsService…"). These names follow the same convention and must be confirmed when the Wave 1 endpoint-group tasks are cut; update this column if the orchestrator picks different names. -2. `POST /api/messages` ships in M2 for plain posting; its scheduled-post (`scheduledAt`) and cross-posting (`mastodonProviderIds`, `crossPostToBluesky`, `crossPostToLinkedIn`) request fields land in M6. The row is checked Implemented at M2; the M6 wave update must confirm the extended fields are covered before the row counts toward M6. +1. **UserService / ExportsService** were not explicitly named in PLAN.md §3 (its service list ends with an ellipsis: "MessagesService, ListsService, DocumentsService, SocialService, OrgService, NotificationsService…"). Wave 1 confirmed the convention: the User endpoint group ships as `InterlinedKit.User` (see `Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift`) and the Exports group as `InterlinedKit.Exports` (`ExportsEndpoint.swift`); domain-side `UserService` / `ExportsService` wrappers are deferred to the milestone in which the consuming UI lands (M6/M7). +2. `POST /api/messages` ships in M2 for plain posting; its scheduled-post (`scheduledAt`) and cross-posting (`mastodonProviderIds`, `crossPostToBluesky`, `crossPostToLinkedIn`) request fields land in M6. The row is checked Implemented at M2; the M6 wave update must confirm the extended fields are covered before the row counts toward M6. Wave 1 note: `MessagesEndpointTests.test_givenCrossPostAndScheduled_whenCreateBuilt_thenEncodesAllSetFields` already exercises encoding for the M6 fields against the builder. 3. Repost (`pushedMessageId`), visibility, and tag filters are request/response fields on existing rows above, not separate endpoints — they carry no row of their own. +4. **Partial test coverage (◐).** The row's request builder, DTOs, and `APIClient.send` path are merged and at least one behavior test exists (typically builder-shape assertion plus one or two of happy/invalid/failure/empty), but the full happy + invalid + failure + empty/boundary quartet required by PLAN.md §7 is not yet present for that specific endpoint. APIClient-level failure decoding is exercised exhaustively in `APIClientTests` / `APIErrorTests`, so per-endpoint failure paths inherit correct error mapping; the gap is dedicated per-endpoint behavior tests. To be backfilled in the milestone in which the row's domain service lands, before the row counts toward that milestone's gate. +5. `POST /api/auth/login` (cookie-session credential exchange) is intentionally not implemented in Wave 1. Decision 0001 makes the Bearer token the primary transport; cookie-session login is needed only for the small session-only allowlist (`/api/user/identities`, `/api/user/organizations`, `/api/exports/*`, `/api/auth/logout`), and is currently stubbed via `NullSessionEstablisher`. A working `SessionEstablisher` calling `POST /api/auth/login` will land alongside the first feature that consumes a session-only endpoint (the Exports menu in M7 or earlier if a feature requires it sooner). +6. `POST /api/auth/register` ships as `AuthService.register` and is exercised by the live `ContractTests` when `INTERLINEDLIST_EMAIL` / `INTERLINEDLIST_PASSWORD` are present, but has no stubbed unit-test cases yet (only `signIn` has dedicated unit tests in `AuthServiceTests`). Tested ☐ until at least happy + invalid + failure + empty/boundary unit tests are added (likely in the onboarding-feature wave). +7. `GET /api/user/organizations` lives in `InterlinedKit.User.organizations()` (not `Organizations.*`) because the live API path is `/api/user/organizations`, not `/api/organizations`. Planned-service column corrected from `OrgService` to `UserService¹` in Wave 1 to match the actual implementation. +8. **No public profile read endpoint exists on the live API.** PLAN.md §1 (Profile row) and §6 M1 ("user profiles") imply a `GET /api/users/[username]` route, but the 2026-06-21 kit-gap spike confirmed every reasonable variation (`/api/users/[username]`, `/api/user/[username]`, `/api/users/[username]/{profile,public}`, `/api/profile/[username]`, `/api/u/[username]`, `/api/public/users/[username]`, `/api/users/[username]/{followers,following}`) returns 404, while the username pattern is otherwise valid (`/api/users/[username]/lists` and `/api/user/[username]/messages` return 200 for the same handle). No such row appears in this matrix because the endpoint is not in the live reference. Decision [`0002-public-profile-fallback`](decisions/0002-public-profile-fallback.md) records the M1 fallback: `SocialService.profile(username:)` reduces to the embedded `{ id, username, displayName, avatar }` author object on the first message returned by `GET /api/user/[username]/messages`. When the upstream endpoint lands, add the row here and check it off against the direct implementation. ## Cross-check against PLAN.md §1 (2026-06-11) - Every API surface named in PLAN.md §1 maps to at least one row above. No PLAN.md endpoint is missing from the live reference. -- Present in the live reference but not explicitly named in PLAN.md §1: `POST /api/auth/logout` (implied by the auth feature), `GET /api/auth/linkedin/status` (supports the LinkedIn cross-post/OAuth feature, M6), and `GET /api/user/[username]/messages` (public user messages; nearest §1 feature is user profiles, M1). None contradict the plan; they are additive. +- Present in the live reference but not explicitly named in PLAN.md §1: `POST /api/auth/logout` (implied by the auth feature), `GET /api/auth/linkedin/status` (supports the LinkedIn cross-post/OAuth feature, M6), and `GET /api/user/[username]/messages` (public user messages; nearest §1 feature is user profiles, M1 — and after the 2026-06-21 spike, this row carries the M1 profile fallback per decision 0002 and footnote 8). +- **PLAN.md §1 surface with no live endpoint:** the Profile-row's natural backing call `GET /api/users/[username]` does not exist on the live API (2026-06-21 spike). Captured in footnote 8 and decision [`0002-public-profile-fallback`](decisions/0002-public-profile-fallback.md); no row added to the matrix above until the upstream endpoint ships. - PLAN.md §4's "Session-only" list (replies, digs, follow, organizations, notifications, document CRUD) matches the live annotations. The live reference additionally marks the User group's write endpoints and Exports as Session — the M0 spike should probe these groups too. + +## Update history + +- **2026-06-21 — Wave 2 update (M1 read-only core consumed).** Domain (`InterlinedDomain`) services + Persistence (`InterlinedPersistence`) SwiftData cache + App-layer Timeline / Lists / Profile UI landed for PLAN.md §6 M1. Per the Wave 1 protocol in footnote 4, every M1-consumed row was promoted from partial (◐⁴) to full (☑) at the domain-service layer. Ten rows flipped: `GET /api/messages`, `GET /api/messages/[id]/replies`, `GET /api/users/[username]/lists`, `GET /api/users/[username]/lists/[id]`, `GET /api/users/[username]/lists/[id]/data`, `GET /api/follow/[userId]/status`, `GET /api/follow/[userId]/followers`, `GET /api/follow/[userId]/following`, `GET /api/follow/[userId]/counts`, and `GET /api/user/[username]/messages`. (`GET /api/messages/[id]` was already ☑ from Wave 1 and is not in the flip count.) **Implemented: 92 of 98 (unchanged). Tested: 16 of 98 fully (☑), 75 of 98 partial (◐⁴), 6 untested ☐ plus 1 untested-with-context ☐⁶.** No new footnotes added. +- **2026-06-21 — Public profile gap recorded.** 2026-06-21 kit-gap spike confirmed `GET /api/users/[username]` (and every reasonable variation) does not exist on the live API. Footnote 8 added; `GET /api/user/[username]/messages` row annotated with footnote 8 to mark its role as the M1 profile fallback carrier per decision [`0002-public-profile-fallback`](decisions/0002-public-profile-fallback.md). No row added to the matrix; no Implemented / Tested counts change. +- **2026-06-18 — Wave 1 update.** InterlinedKit endpoint groups (Auth additive, User, Messages, Lists, Documents & Sync, Follow, Organizations, Notifications, Exports) merged in commits `86eea76`, `a1e6d1c`, `6ed194a`. **Implemented: 92 of 98** (the 6 unimplemented rows are `POST /api/auth/login` ⁵, the four OAuth `authorize` endpoints, and `GET /api/auth/linkedin/status` — all M6/M7). **Tested: 6 of 98 fully (☑), 85 of 98 partial (◐⁴), 6 untested ☐ plus 1 untested-with-context ☐⁶.** Footnote 1 resolved (planned-service column matches code). Footnote 7 added: `GET /api/user/organizations` belongs to the `User` namespace, not `Organizations`. Footnotes 5 (login deferred) and 6 (register lacks dedicated stubbed unit tests) added. diff --git a/docs/decisions/0002-public-profile-fallback.md b/docs/decisions/0002-public-profile-fallback.md new file mode 100644 index 0000000..75d5bf3 --- /dev/null +++ b/docs/decisions/0002-public-profile-fallback.md @@ -0,0 +1,125 @@ +# 0002 — Public user profile fallback via embedded author + +- **Status:** Accepted (2026-06-21) +- **Date:** 2026-06-21 +- **Context:** PLAN.md §1 (Profile / Follow-system rows), §6 M1 (user profiles), §7 (coverage matrix discipline); decision 0001 (auth transport) +- **Supersedes / superseded by:** — + +## Context + +PLAN.md §1 commits M1 to "user profiles" as part of the read-only core, +implying a `SocialService.profile(username:)` call that resolves a +`UserProfile` for any public handle. The natural backing endpoint would be +`GET /api/users/[username]` — but neither the [API reference][api] nor any +read-only probe finds it. Wave 1 shipped the three `/api/users/[username]/lists*` +rows and `/api/user/[username]/messages` against the live API; the public +profile read was the only Profile-row gap. + +The 2026-06-21 kit-gap spike was run to settle whether the endpoint exists +under a different shape, so M1 can either implement against it or commit to a +documented fallback before the Domain agent wires `SocialService`. + +[api]: https://interlinedlist.com/help/api + +## Evidence + +**Live-API probes (read-only, no creds), 2026-06-21.** All of the following +returned `404 Not Found`: + +- `GET /api/users/[username]` +- `GET /api/user/[username]` +- `GET /api/users/[username]/profile` +- `GET /api/users/[username]/public` +- `GET /api/profile/[username]` +- `GET /api/u/[username]` +- `GET /api/public/users/[username]` +- `GET /api/users/[username]/followers` +- `GET /api/users/[username]/following` + +**Control probes (same run, same username) returned `200 OK`:** + +- `GET /api/users/[username]/lists` +- `GET /api/user/[username]/messages` + +The username path segment is therefore valid; the public profile route +genuinely does not exist. + +**Docs review.** [https://interlinedlist.com/help/api][api] documents no public +profile read endpoint. The only routes under `/api/users/[username]*` or +`/api/user/[username]*` are: the three public-list reads (already shipped +in Wave 1), `/api/user/[username]/messages` (public messages), and the +unrelated `/api/auth/linkedin/status`. Follower/following enumeration is +session-only via `/api/follow/[userId]/{followers,following}` and requires a +user ID, not a username. + +**Embedded-author shape.** `GET /api/user/[username]/messages` embeds the +author user object on every message, matching `MessageAuthorDTO` already in +`InterlinedKit`: + +``` +{ "id": "...", "username": "...", "displayName": "...", "avatar": "..." } +``` + +No `bio`, `joinedAt`, `isPrivate`, or follower counts are present. (See +`Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift` — +`MessageAuthorDTO`.) + +## Decision + +`SocialService.profile(username:)` is implemented for M1 against the embedded +author of a public message, not a direct profile read: + +- Implementation calls `Messages.publicUserMessages(username:, limit: 1)` and + extracts the embedded `MessageAuthorDTO` from the first message. +- The returned `UserProfile` populates only `{ id, username, displayName, + avatar }`. All other fields (`bio`, `joinedAt`, `isPrivate`, follower + counts) are explicitly `nil`. This nil-ness is documented on the model and + pinned by a BDD test in the Domain suite. +- A user with zero public messages cannot be resolved this way. The service + throws a typed domain error in that case — name owned by the Domain agent + (e.g. `SocialError.profileUnavailable(username:)`). +- A `UserProfile.init(fromEmbeddedAuthorOf: MessageDTO)` mapper is provided on + the Domain side so the same reduction is reusable from any timeline / detail + surface that already holds a message — no extra network round-trip when the + caller has a message in hand. + +## Consequences + +- **M1 profile UI is a thin header.** Avatar + display name + handle is the + full set of fields the service can return. Richer profile chrome (bio, join + date, lock icon for private accounts, follower / following counts) is + deferred. The view model and SwiftUI view must be designed to render the + reduced field set without empty-cell placeholders. +- **Zero-public-messages users are a dead end in M1.** A friendly empty + state ("This profile has no public messages yet") is the M1 UX. This is + not a defect to fix in M1; it is the documented limit of the fallback. +- **Upstream API request to file.** A feature request for + `GET /api/users/[username]` (public, returns the full profile shape + including `bio`, `joinedAt`, `isPrivate`, follower counts) should be filed + against the InterlinedList API. Tracking issue and link to be added here + when filed. +- **M5 (Social & notifications) inherits a dependency.** Follower / following + list screens at M5 still go through `/api/follow/[userId]/*` (which takes a + user ID), so they need a username → userId resolution. The M1 fallback + satisfies that resolution path (embedded author carries `id`), so M5 is not + blocked — but if the upstream profile endpoint lands first, M5 should adopt + it for the cleaner shape (richer header + the same `id`). +- **Coverage matrix.** No row exists today for `GET /api/users/[username]` + because the endpoint is not in the live reference. A footnote on + `docs/api-coverage.md` records the missing endpoint, points to this + decision, and notes the M1 fallback. If the endpoint later lands, add the + row at that wave's matrix update and check it off against the new direct + implementation. + +## Revival path + +When `GET /api/users/[username]` lands upstream: + +1. Add the row to `docs/api-coverage.md` (User or Public group; auth = None). +2. Add the request builder + DTO to `InterlinedKit.User` (or + `InterlinedKit.Public` if the live group is `Public`). +3. Swap `SocialService.profile(username:)` to call the direct endpoint and + populate the full `UserProfile`. +4. Keep `UserProfile.init(fromEmbeddedAuthorOf:)` as a degraded-mode mapper + for offline / cache scenarios where only an embedded author is on hand. +5. Update this decision's status to "Superseded by 000N" and add the link. diff --git a/docs/progress.md b/docs/progress.md index cd32b87..95173f5 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -51,3 +51,170 @@ See [decisions/0001-auth-transport.md](decisions/0001-auth-transport.md) and [sp --- _Wave 1 (InterlinedKit core) and later entries are appended below this line as waves complete._ + +--- + +## Wave 1 — InterlinedKit core (PLAN.md §6 M0 closure → InterlinedKit slice of every milestone) + +Wave 1 builds the request-builder + DTO layer for every documented endpoint group inside `Packages/InterlinedKit`, leaving service-layer composition (per PLAN.md §3) for the milestone in which each domain service first lands. Path ownership stayed inside `Packages/InterlinedKit/Sources/InterlinedKit/{Endpoints,DTOs,Auth,APIClient,Errors,Pagination}/**` and `Packages/InterlinedKit/Tests/InterlinedKitTests/**`; no app, persistence, or docs paths were touched outside this update. + +### 1.1 — APIClient + Auth/TokenStore + error mapping + pagination — DONE +- `APIClient` (`Packages/InterlinedKit/Sources/InterlinedKit/APIClient/`) — protocol + `URLSession`-backed implementation with `send`, `sendVoid`, and `sendRaw` (CSV exports), plus the 401 safety-net that retries through the cookie-session transport when a Bearer request is rejected (decision 0001). +- `TokenStore` (Keychain-backed) and `InMemoryTokenStore` test double. +- `AuthTransport` seam (`DefaultAuthTransport` + `SessionEstablisher` protocol with `NullSessionEstablisher` test double) — the implementation half of the decision-0001 Bearer-default / lazy-session-fallback design. +- `APIError` mapping for the `{ "error": ... }` body and HTTP status families (`badRequest`, `unauthorized`, `forbidden`, `notFound`, `httpStatus`, `decoding`, `transport`). +- `Paginated` + `PaginationInfo` + `PaginatedDecoder` (collection-key-driven) + `PageIterator`. + +### 1.2 — Messages / User / Auth endpoint groups + live contract test — DONE +- Commit: `86eea76`. +- `Endpoints/MessagesEndpoint.swift` + `DTOs/MessageDTO.swift`: 11 Messages rows including `Paginated` for `list` / `userMessages`, non-standard envelopes for `scheduled` (`ScheduledMessagesResponse`) and `replies` (`RepliesResponse`), `dig`/`undig`, and raw-body uploads. +- `Endpoints/UserEndpoint.swift` + `DTOs/UserDTO.swift`: 8 User rows; the two confirmed session-only reads (`/api/user/identities`, `/api/user/organizations`) carry `auth: .session` per decision 0001. +- `Endpoints/AuthEndpoint.swift` + `DTOs/AuthDTO.swift`: 5 additive Auth rows (`forgotPassword`, `resetPassword`, `sendVerificationEmail`, `verifyEmail`, `logout`). `AuthService` retains ownership of the credential-exchange endpoints (`signIn` → `/api/auth/sync-token`, `register` → `/api/auth/register`) so token-persistence side effects stay co-located. +- `ContractTests.swift` added — env-gated live `POST /api/auth/sync-token` + `GET /api/messages?limit=3` against `https://interlinedlist.com`, never logging credentials or tokens; skipped when `INTERLINEDLIST_EMAIL` / `INTERLINEDLIST_PASSWORD` are absent. +- `AuthService.requestPasswordReset` re-routed from the previously-coded `/api/auth/password-reset/request` (404 on live API) to the working `/api/auth/forgot-password`; legacy `PasswordResetRequest` retained but marked `@available(deprecated)`. + +### 1.3 — Lists / Documents / Follow / Orgs / Notifications / Exports endpoint groups — DONE +- Commits: `a1e6d1c` (parallel groups), `6ed194a` (merge). +- `Endpoints/ListsEndpoint.swift` + `DTOs/ListDTO.swift`: 21 Lists rows + 3 List Connections rows. Includes dynamic-schema `ListRowDTO.rowData` (`[String: ListJSONValue]`), public no-auth browse routes, watchers, and connections. +- `Endpoints/DocumentsEndpoint.swift` + `DTOs/DocumentDTO.swift`: 14 Documents & Sync rows including the delta-sync read (`DocumentSyncResponse`), push-sync write (`DocumentSyncRequest`), folder CRUD, and multipart image upload via `RequestBody.raw`. +- `Endpoints/FollowEndpoint.swift` + `DTOs/FollowDTO.swift`: 11 Follow rows. Listing-shape ambiguity for followers/following/mutual called out in the file's doc comment — typed as bare arrays; switchable to `Paginated` without changing call sites if the live envelope turns out to be wrapped. +- `Endpoints/OrganizationsEndpoint.swift` + `DTOs/OrganizationDTO.swift`: 9 Organizations rows including the `addMember` envelope (`OrganizationMembershipResponse`). +- `Endpoints/NotificationsEndpoint.swift` + `DTOs/NotificationDTO.swift`: 3 Notifications rows. Non-standard tray envelope (`{ unreadCount, items }`) modeled as `NotificationTrayDTO`; `scope=tray` query parameter defaulted. +- `Endpoints/ExportsEndpoint.swift` + `DTOs/ExportDTO.swift`: 4 Exports rows — `auth: .session` per decision 0001 allowlist; CSV responses retrieved via `APIClient.sendRaw` + `CSVExport.from(_:)` (not JSON-decoded). + +### Test counts per suite (`swift test --package-path Packages/InterlinedKit`, run 2026-06-18) + +| Suite | Tests | Notes | +| --- | ---: | --- | +| `APIClientTests` | 10 | Send / sendVoid / sendRaw, header injection, JSON encoding, 401 safety-net. | +| `APIErrorTests` | 13 | Status-code → `APIError` mapping incl. malformed bodies. | +| `AuthEndpointTests` | 16 | Builders + `forgotPassword`, `resetPassword`, `sendVerificationEmail`, `verifyEmail`, `logout` round-trips. | +| `AuthServiceTests` | 7 | `signIn` happy / invalid / failure, `signOut`, `hasStoredToken`. | +| `AuthTransportTests` | 6 | `DefaultAuthTransport` routing and 401 retry. | +| `ContractTests` | 2 | Env-gated live API; **both skipped in this run** (no `INTERLINEDLIST_EMAIL` / `INTERLINEDLIST_PASSWORD`). | +| `DocumentsEndpointTests` | 9 | Builder shape + sync delta + image upload + sync push + boundary cases. | +| `ExportsEndpointTests` | 6 | Builders + CSV bytes via `sendRaw` over session transport + forbidden failure + boundary. | +| `FollowEndpointTests` | 9 | Builders + status / counts / followers + unauthorized retry path. | +| `ListsEndpointTests` | 11 | Builders + paginated decode + dynamic-schema row + connections envelope. | +| `MessagesEndpointTests` | 34 | Builders + create (incl. M6 cross-post fields) + scheduled + replies + dig/undig + raw upload + public messages. | +| `NotificationsEndpointTests` | 7 | Builders + tray decode + mark-read + mark-all-read + boundary + malformed. | +| `OrganizationsEndpointTests` | 8 | Builders + list / members / add-member envelope + boundary. | +| `PaginationTests` | 8 | `Paginated` decoder across collection keys + error cases. | +| `TokenStoreTests` | 5 | Read / write / overwrite / delete on `InMemoryTokenStore`. | +| `UserEndpointTests` | 23 | Builders + envelopes + identities & organizations through session transport + update / avatar / change-email / delete. | +| **Total** | **174** | All passing; 0 failures. | + +### Wave 1 gate — PASSED (2026-06-18) + +- `swift test --package-path Packages/InterlinedKit` → **174/174 passing** (12.2 s, including the 1.5 s spent in the two env-gated `ContractTests` cases that issue an `XCTSkip` because no credentials are present). +- Coverage matrix delta: **0 → 92 Implemented** (☑), **0 → 6 fully Tested** (☑) plus **85 partial** (◐⁴) — see `api-coverage.md` for the full row breakdown and the new partial-coverage convention. + +### Coverage matrix delta (after this update) + +| | Before Wave 1 | After Wave 1 | +| --- | ---: | ---: | +| Implemented (☑) | 0 / 98 | **92 / 98** | +| Tested fully (☑) | 0 / 98 | **6 / 98** | +| Tested partial (◐⁴) | 0 / 98 | **85 / 98** | +| Untested (☐) | 98 / 98 | **7 / 98** | + +The 6 unimplemented rows (`POST /api/auth/login` ⁵, the four `GET /api/auth/.../authorize` OAuth rows, and `GET /api/auth/linkedin/status`) are all M6/M7 work and were correctly out of Wave 1 scope. `POST /api/auth/register` is implemented in `AuthService` but lacks dedicated stubbed unit tests (footnote 6); the other 6 untested rows are the unimplemented rows above. + +### Deviations and follow-ups + +1. **Planned-service column corrections (matrix footnote 1, 7).** `UserService` and `ExportsService` names from PLAN.md §3's ellipsis are confirmed by the Wave 1 implementation as `InterlinedKit.User` / `InterlinedKit.Exports` namespaces. `GET /api/user/organizations` was previously listed under `OrgService`; corrected to `UserService¹` because the live path is `/api/user/organizations` and the builder lives in `User`. +2. **`POST /api/auth/login` deferred (matrix footnote 5).** Decision 0001 makes Bearer the primary transport; cookie-session login is needed only to satisfy the small session-only allowlist. Wave 1 wires the seam (`SessionEstablisher`) but ships only the `NullSessionEstablisher` test double — a real one calling `POST /api/auth/login` lands with the first feature that exercises a session-only endpoint (likely the M7 Exports menu, or earlier if a Wave 2 feature requires it). No row contradicts PLAN.md; the deferral is a sequencing choice. +3. **Path correction in `AuthService.requestPasswordReset`.** The previously-coded `/api/auth/password-reset/request` returns 404 on the live API; `forgotPassword` is the working endpoint. `PasswordResetRequest` retained as a deprecated source-compat stub; consumers should migrate to `ForgotPasswordRequest`. +4. **`MessagesEndpointTests` M6 carry-in (matrix footnote 2).** `test_givenCrossPostAndScheduled_whenCreateBuilt_thenEncodesAllSetFields` already exercises encoding for `scheduledAt`, `mastodonProviderIds`, `crossPostToBluesky`, and `crossPostToLinkedIn`, so the M6 wave update need only confirm a domain-service path consumes those fields before the row counts toward M6. +5. **Follower / following / mutual listing envelope unknown.** `FollowEndpoint`'s file-level doc records the open assumption: bare arrays of `FollowUserDTO`, with a one-line switch to `Paginated` if the live API turns out to wrap them under `"data"`. The contract test only covers the timeline today — flagged for either a follow-up live probe or a Wave 5 (Social) confirmation when the social-feature work begins. +6. **Per-endpoint behavior depth (footnote 4).** Of 92 implemented rows, only 6 carry the full happy + invalid + failure + empty/boundary quartet PLAN.md §7 requires. The remaining 85 are marked partial (◐⁴): every row has at least a builder-shape assertion and at least one behavior case, and `APIClientTests` / `APIErrorTests` exhaustively cover the cross-cutting error mapping that every endpoint inherits. The pragmatic call: backfill per-endpoint quartet tests in the milestone wave that ships the consuming domain service, so the per-endpoint tests can pin both the request builder and the service's call site simultaneously. Each subsequent wave's documentation update must convert the partial (◐⁴) rows for endpoints it consumes into full ☑ before the wave gate. +7. **OAuth endpoints and `linkedin/status` intentionally deferred to M6** (matrix milestone column already reflects this). + +--- + +## Wave 2 — InterlinedDomain / Persistence / M1 UI (PLAN.md §6 M1) + +Wave 2 lands the M1 read-only core: the `InterlinedDomain` slice (models + services), the `InterlinedPersistence` SwiftData timeline cache, and the App-layer composition root + Timeline / Lists / Profile features. Path ownership stayed inside `Packages/InterlinedDomain/**`, `Packages/InterlinedPersistence/**`, and `App/**`; no `InterlinedKit` source paths were touched this wave (its 174/174 suite from Wave 1 is unchanged). Write-side M5 social methods were explicitly deferred — `SocialService` ships read-only. + +### Decisions + +- **2026-06-21 — Decision 0002 recorded.** Public user profile (`GET /api/users/[username]`) does not exist on the live API; M1 `SocialService.profile(username:)` falls back to the embedded author of `GET /api/user/[username]/messages` (fields limited to `{ id, username, displayName, avatar }`). See [decisions/0002-public-profile-fallback.md](decisions/0002-public-profile-fallback.md). Coverage matrix updated with footnote 8 and the `GET /api/user/[username]/messages` row annotated as the M1 profile carrier; no Implemented / Tested counts change. + +### 2.1 — InterlinedDomain slice (Messages / Session / Entitlements) — DONE + +- Commit: `b33d66c`. +- Domain models for the M1 read surface: `MessageBody`, `MessageAuthor`, `MessageThread` and their mappers off `MessageDTO` / `RepliesResponse`. +- `MessagesService` — paged timeline (all / mine / tag scopes), message-by-id read, replies-by-id read, public-author messages — all routed through `InterlinedKit.Messages` and the Wave 1 `APIClient`. +- `SessionService` — current-user read off `GET /api/user`, surfaced as a `Session` domain value; `EntitlementsService` reads `customerStatus` from the same payload. +- BDD-named unit tests against `APIClient` stubs; this slice raised the Domain suite to 61 passing tests at commit time. + +### 2.2 — Lists & Social domain services + models — DONE + +- New domain models: `UserProfile` (incl. `UserProfile.init(fromEmbeddedAuthorOf: MessageDTO)` per decision 0002), `ListSummary`, `ListDetail`, `ListRow`, plus `ListMappers` and `ProfileMappers`. +- `ListsService` (public-browse only for M1): `lists(of:limit:offset:)`, `detail(username:slug:)`, `rows(username:slug:limit:offset:)` — backed by the three `GET /api/users/[username]/lists*` rows. +- `SocialService` (read-only for M1): `profile(username:)` via the decision-0002 embedded-author fallback, plus `status(of:)`, `counts(of:)`, `followers(of:limit:offset:)`, `following(of:limit:offset:)`. Surfaces a typed `SocialError.profileUnavailable(username:)` for the empty-public-messages case. M5 write methods (follow / unfollow / approve / reject / remove / mutual / requests) are explicitly deferred to the M5 wave. +- Tests: `ListsServiceTests`, `SocialServiceTests` — 15 new BDD-named cases covering the embedded-author projection, the nil-rich-fields M1 guarantee from decision 0002, and the empty-public-messages error path. +- Domain suite now passes **76/76**. + +### 2.3 — InterlinedPersistence SwiftData cache — DONE + +- New folder layout inside `Packages/InterlinedPersistence/Sources/InterlinedPersistence/`: + - `Schema/` — `MessageRecord.swift` (SwiftData `@Model`), `TimelinePageRecord.swift` (page key + ordered message IDs). + - `Mapping/` — `MessageRecordMapping.swift` (round-trip between `Message` domain value and `MessageRecord`). + - `Stores/` — `SwiftDataMessageStore.swift` (`MessageStore` implementation with in-memory and on-disk factories; `NullMessageStore` no-op for hostile boot conditions). +- `SwiftDataMessageStoreTests.swift` — 10 new BDD-named cases covering round-trip, second-write-wins, cross-key isolation, clear, repost hydration, and the repost dropped-silently-when-missing path. +- Persistence suite now passes **13/13**. + +### 2.4 — App-layer composition root + Timeline / Lists / Profile UI — DONE + +- `App/Composition/AppEnvironment.swift` — composition root wiring `KeychainTokenStore` → `DefaultAuthTransport` (Bearer-only, `NullSessionEstablisher` for M1 per decision 0001) → `APIClient` → `SwiftDataMessageStore.inMemory()` → `MessagesService`, with a shared-client `ListsService` and `SocialService`. Exposed to SwiftUI via `@EnvironmentObject` and the `\.appEnvironment` environment key. Falls back to `NullMessageStore` if the in-memory SwiftData store can't be constructed. +- Timeline feature: `TimelineRootView`, `TimelineViewModel`, `MessageRowView`, `MessageDetailView`, `MessageDetailViewModel`. Scope picker (All / Mine), tag filter, infinite scroll (paged at row N-5), pull-to-refresh, detail thread view. +- Lists feature: `ListsBrowserView`, `ListsBrowserViewModel`, `ListRowSummaryView`, `ListDetailView`, `ListDetailViewModel`. Username → public-list browse with list detail + rows. +- Social feature: `ProfileHeaderView`, `ProfileViewModel`, `ProfileRootView`. +- `App/Navigation/MainWindowView.swift` — `SidebarDetailDispatcher` now routes `.timeline → TimelineRootView()`, `.lists → ListsBrowserView()`, `.profile → ProfileRootView()`. Remaining sections (Scheduled, Notifications, Documents, Organizations) stay on placeholders pending later milestones. +- `App/InterlinedListApp.swift` — installs `AppEnvironment.live()` at scene level. +- `TimelinePlaceholderView`, `ListsPlaceholderView`, `SocialPlaceholderView` retained as preview fallbacks with docstrings updated to note they're superseded. + +### Wave 2 gate — PASSED (2026-06-21) + +- App build: `xcodebuild -scheme InterlinedList -destination 'platform=macOS' build` → **BUILD SUCCEEDED**. +- Domain tests: `swift test --package-path Packages/InterlinedDomain` → **76/76 passing**. +- Persistence tests: `swift test --package-path Packages/InterlinedPersistence` → **13/13 passing**. +- InterlinedKit suite unchanged this wave — still **174/174** from Wave 1 (no source paths in `Packages/InterlinedKit/**` touched). +- Path-ownership check: changes confined to `Packages/InterlinedDomain/**`, `Packages/InterlinedPersistence/**`, `App/**`, and `docs/**` — no overlaps with Wave 1 paths; conflict rules held. + +### Test counts per suite (combined Domain + Persistence, run 2026-06-21) + +| Suite | Tests | Notes | +| --- | ---: | --- | +| `InterlinedDomainTests` (full) | 76 | Includes the new `ListsServiceTests` and `SocialServiceTests` (15 new cases this wave) on top of the 2.1 Messages / Session / Entitlements suites. | +| `InterlinedPersistenceTests` (full) | 13 | Includes the 10 new `SwiftDataMessageStoreTests` cases this wave on top of the Wave 0 baseline. | +| **Total (Domain + Persistence)** | **89** | All passing; 0 failures. InterlinedKit unchanged at 174/174. | + +### Coverage matrix delta (after this update) + +The M1 consumption rule from Wave 1 deviation 6 applied: every M1-consumed row that was partial (◐⁴) after Wave 1 is now fully tested (☑) at the domain-service layer. + +| | Before Wave 2 | After Wave 2 | +| --- | ---: | ---: | +| Implemented (☑) | 92 / 98 | **92 / 98** | +| Tested fully (☑) | 6 / 98 | **16 / 98** | +| Tested partial (◐⁴) | 85 / 98 | **75 / 98** | +| Untested (☐) | 7 / 98 | **7 / 98** | + +Rows flipped ◐⁴ → ☑ this wave (10 total, all M1): + +- Messages: `GET /api/messages`, `GET /api/messages/[id]/replies` (`GET /api/messages/[id]` was already ☑ from Wave 1). +- Lists (public): `GET /api/users/[username]/lists`, `GET /api/users/[username]/lists/[id]`, `GET /api/users/[username]/lists/[id]/data`. +- Follow (read-only M1 subset): `GET /api/follow/[userId]/status`, `GET /api/follow/[userId]/counts`, `GET /api/follow/[userId]/followers`, `GET /api/follow/[userId]/following`. The remaining Follow rows (write paths, mutual, requests) stay ◐⁴ for M5. +- Public: `GET /api/user/[username]/messages`. + +`GET /api/messages/[id]` was already ☑ after Wave 1 (per Wave 1's 6 fully-tested rows) and remains ☑; it is consumed by M1 detail view but contributes no flip to the total above. + +### Deviations and follow-ups + +1. **Latent kit-import compile failure surfaced when wiring `.profile` to `ProfileRootView`.** `ProfileHeaderView` and `ProfileViewModel` referenced `FollowCountsDTO` (an `InterlinedKit` type) while importing only `InterlinedDomain`. The defect was masked through earlier 2.4 iterations because the `SidebarDetailDispatcher` still routed `.profile` to `SocialPlaceholderView`, so the App target never compiled the new files. The dispatcher change to `ProfileRootView()` first exposed the missing import; fixed by adding `import InterlinedKit` to both files. **Recommendation for a Wave 3 architectural decision (do not implement in this docs task):** either codify a rule that any App-layer file referencing a kit DTO must `import InterlinedKit`, OR adopt `@_exported import InterlinedKit` from `InterlinedDomain` to re-export the kit's public surface so domain consumers do not have to know which layer a type lives in. The trade-off (surface-area leak vs. consumer ergonomics) belongs in a Wave 3 decision record before either path is taken. +2. **`SocialService` ships read-only for M1.** Write methods (`follow`, `unfollow`, `approve`, `reject`, `remove`, `mutual`, `requests`) are deferred to the M5 wave per PLAN.md §6; the Wave 1 `InterlinedKit.Follow` builders for those rows stay at ◐⁴ until M5 consumes them. +3. **List rows are projected via `ListRow` with dynamic `rowData`.** The M1 list-detail UI renders rows with the dynamic-schema `rowData` typed as `[String: ListJSONValue]` from `InterlinedKit.ListRowDTO`. No schema-driven typing for row values is in scope until M3 (Lists CRUD). +4. **Composition root uses `SwiftDataMessageStore.inMemory()` for M1.** On-disk persistence is wired (the factory exists) but the live `AppEnvironment` defaults to the in-memory store so the M1 read-only core does not need a schema-migration story before M4. A `NullMessageStore` is the documented fallback if the in-memory store fails to construct at boot. +5. **Zero-public-messages users surface as `SocialError.profileUnavailable(username:)`.** This is the documented limit of the decision-0002 fallback, not a defect; M1 UX renders the typed error as a friendly empty state. Will resolve when the upstream `GET /api/users/[username]` lands and decision 0002 is superseded. From c07ac8aaf9e9938fe47589ff3c98a5f1ca16a9d5 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 22 Jun 2026 14:57:59 -0700 Subject: [PATCH 02/16] Wave 3 (M2 kickoff): Decision 0003 + MessagesService write methods + FollowCounts domain model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 0003 (docs/decisions/0003-kit-import-policy.md): App-layer files must import InterlinedKit explicitly; InterlinedDomain re-exports nothing. Resolves Wave 2 deviation #1 by introducing the missing FollowCounts domain model and removing the kit import from the three Social views. InterlinedDomain — M2 write surface on MessagesService: - create(body:parentId:tags:visibility:pushedMessageId:) - reply(to:body:tags:visibility:) - repost(_:commentary:visibility:) - update(messageId:body:tags:visibility:) - delete(messageId:) - dig(messageId:) / undig(messageId:) All wrap the existing Wave 1 InterlinedKit.Messages builders. Cross-post / scheduling / media fields stay deferred to M6 per the doc TODOs. MessageStore gains a remove(id:) method with a no-op default so the out-of-package SwiftDataMessageStore conformance compiles unchanged; InMemoryMessageStore implements concrete eviction across by-id and timeline indexes so delete(messageId:) leaves no tombstoned cache. Tests: InterlinedDomain 76 → 99 (BDD-named quartet — happy/invalid/ failure/empty — for each new write method, plus the FollowCounts mapper and the new InMemoryMessageStore.remove). InterlinedPersistence 13/13 unchanged. InterlinedKit 174/174 unchanged (no kit sources touched). xcodebuild -scheme InterlinedList -destination 'platform=macOS' build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) --- App/Features/Social/ProfileHeaderView.swift | 14 +- App/Features/Social/ProfileRootView.swift | 6 +- App/Features/Social/ProfileViewModel.swift | 6 +- .../Caching/InMemoryMessageStore.swift | 10 + .../Caching/MessageStore.swift | 22 + .../Models/FollowCounts.swift | 35 ++ .../Models/FollowMappers.swift | 21 + .../Models/ProfileMappers.swift | 10 +- .../Services/MessagesService.swift | 194 +++++++++ .../Services/SocialService.swift | 11 +- .../InMemoryMessageStoreTests.swift | 33 ++ .../InterlinedDomainTests/MapperTests.swift | 30 ++ .../MessagesServiceTests.swift | 380 ++++++++++++++++++ docs/decisions/0003-kit-import-policy.md | 123 ++++++ 14 files changed, 879 insertions(+), 16 deletions(-) create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowCounts.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowMappers.swift create mode 100644 docs/decisions/0003-kit-import-policy.md diff --git a/App/Features/Social/ProfileHeaderView.swift b/App/Features/Social/ProfileHeaderView.swift index 44ef0bd..6016c5c 100644 --- a/App/Features/Social/ProfileHeaderView.swift +++ b/App/Features/Social/ProfileHeaderView.swift @@ -1,7 +1,7 @@ // ProfileHeaderView // // Read-only public profile header (PLAN.md §1 "Profile", §6 M1). Pure -// presentation: it takes a `UserProfile` plus an optional `FollowCountsDTO` +// presentation: it takes a `UserProfile` plus an optional `FollowCounts` // and renders avatar, display name, handle, and the conditional fields. // // Per `docs/decisions/0002-public-profile-fallback.md`, the M1 fallback @@ -16,10 +16,12 @@ // private badge" — the badge is only ever rendered when the value // transitions to `true`, which won't happen until the upstream profile // endpoint lands and the decision is revived. +// +// Per decision 0003 (App-layer Kit-import policy), this view consumes the +// domain `FollowCounts` and does not `import InterlinedKit`. import SwiftUI import InterlinedDomain -import InterlinedKit struct ProfileHeaderView: View { @@ -27,7 +29,7 @@ struct ProfileHeaderView: View { /// Optional follow-counts follow-up. `nil` while the call is in /// flight or after it failed (the failure is soft — see /// `ProfileViewModel.loadProfile`). - let counts: FollowCountsDTO? + let counts: FollowCounts? var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -139,15 +141,15 @@ struct ProfileHeaderView: View { } @ViewBuilder - private func countsRow(counts: FollowCountsDTO) -> some View { + private func countsRow(counts: FollowCounts) -> some View { HStack(spacing: 24) { countPill( - value: counts.followerCount, + value: counts.followers, singular: "follower", plural: "followers" ) countPill( - value: counts.followingCount, + value: counts.following, singular: "following", plural: "following" ) diff --git a/App/Features/Social/ProfileRootView.swift b/App/Features/Social/ProfileRootView.swift index 1b4529e..b04a017 100644 --- a/App/Features/Social/ProfileRootView.swift +++ b/App/Features/Social/ProfileRootView.swift @@ -15,10 +15,12 @@ // The view is a thin shell over `ProfileViewModel`: it observes state, // dispatches user intents, and leaves all loading / error logic in the // view model so unit tests cover the behavior without touching SwiftUI. +// +// Per decision 0003 (App-layer Kit-import policy), this view consumes the +// domain `FollowCounts` and does not `import InterlinedKit`. import SwiftUI import InterlinedDomain -import InterlinedKit struct ProfileRootView: View { @@ -127,7 +129,7 @@ struct ProfileRootView: View { } @ViewBuilder - private func profileSection(profile: UserProfile, counts: FollowCountsDTO?) -> some View { + private func profileSection(profile: UserProfile, counts: FollowCounts?) -> some View { ScrollView { ProfileHeaderView(profile: profile, counts: counts) } diff --git a/App/Features/Social/ProfileViewModel.swift b/App/Features/Social/ProfileViewModel.swift index 8e27414..c82d411 100644 --- a/App/Features/Social/ProfileViewModel.swift +++ b/App/Features/Social/ProfileViewModel.swift @@ -19,11 +19,13 @@ // kit-level builder (`Messages.userMessages(username:limit:offset:)`) // exists but isn't wrapped by `MessagesService`, and the App layer is not // permitted to call the kit directly (layering — see PLAN.md §3). +// +// Per decision 0003 (App-layer Kit-import policy), this view model consumes +// the domain `FollowCounts` and does not `import InterlinedKit`. import Foundation import Observation import InterlinedDomain -import InterlinedKit @MainActor @Observable @@ -54,7 +56,7 @@ final class ProfileViewModel { /// `social.counts(of:)` call. `nil` when the counts request hasn't /// completed yet or failed. Counts failure is *soft* — `profile` /// stays set so the header still renders. - private(set) var counts: FollowCountsDTO? + private(set) var counts: FollowCounts? /// True while either the profile load or the counts follow-up is in /// flight. The view shows a single progress indicator for both. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/InMemoryMessageStore.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/InMemoryMessageStore.swift index 4681a11..8c0afb2 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/InMemoryMessageStore.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/InMemoryMessageStore.swift @@ -41,6 +41,16 @@ public actor InMemoryMessageStore: MessageStore { } } + public func remove(id: String) async { + messagesByID.removeValue(forKey: id) + for (key, messages) in timelines { + let filtered = messages.filter { $0.id != id } + if filtered.count != messages.count { + timelines[key] = filtered + } + } + } + public func clear() async { timelines.removeAll() messagesByID.removeAll() diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/MessageStore.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/MessageStore.swift index 3782b07..babe298 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/MessageStore.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Caching/MessageStore.swift @@ -23,6 +23,28 @@ public protocol MessageStore: Sendable { /// Inserts or updates messages in the by-id cache. func upsert(_ messages: [Message]) async + /// Removes a single message from every cached slice (by-id + every + /// timeline list it appeared in). Called after a successful + /// `DELETE /api/messages/[id]` so the UI never re-renders a tombstoned + /// message from cache. Missing-id is a no-op (the protocol does not + /// throw — cache state is best-effort, see protocol doc). + /// + /// A default no-op implementation is provided below so existing + /// conformances (e.g. the persistence layer's SwiftData store, owned + /// by a different package) compile without immediate change. The + /// `InMemoryMessageStore` overrides it; the persistence store should + /// adopt a concrete implementation in the next persistence wave so the + /// on-disk cache stays consistent on delete. + func remove(id: String) async + /// Clears all cached state. Called on sign-out. func clear() async } + +public extension MessageStore { + /// Default no-op so the new protocol method does not break existing + /// out-of-package conformances written before it landed. Concrete stores + /// should override to keep the cache consistent with deletes; see the + /// protocol's `remove(id:)` doc. + func remove(id: String) async { } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowCounts.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowCounts.swift new file mode 100644 index 0000000..b3ad100 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowCounts.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Follower and following counts for a user, as the M1 profile header (and the +/// M5 social UIs) render them (PLAN.md §1 "Follow system"). +/// +/// This is the domain projection of `InterlinedKit.FollowCountsDTO`. It exists +/// so App-layer files never have to `import InterlinedKit` to display +/// follower counts (decision 0003 — App-layer Kit-import policy). The mapping +/// is total and lossless — the DTO has exactly these two fields — but +/// modelling the value here keeps the layering rule intact: the Domain layer +/// is the App's vocabulary; the DTO never crosses into the UI. +public struct FollowCounts: Sendable, Equatable, Hashable { + + /// Number of accounts that follow this user. + public let followers: Int + + /// Number of accounts this user follows. + public let following: Int + + public init(followers: Int, following: Int) { + self.followers = followers + self.following = following + } + + /// Compatibility alias — the DTO uses `followerCount`. Kept as a computed + /// property so existing view code that read `.followerCount` against the + /// DTO continues to compile against the domain value without ceremony. + public var followerCount: Int { followers } + + /// Compatibility alias — the DTO uses `followingCount`. See `followerCount`. + public var followingCount: Int { following } + + /// The boundary value — a brand-new account with no follow relationships. + public static let zero = FollowCounts(followers: 0, following: 0) +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowMappers.swift new file mode 100644 index 0000000..1c82b81 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/FollowMappers.swift @@ -0,0 +1,21 @@ +import Foundation +import InterlinedKit + +// MARK: - Follow DTO → domain mapping +// +// Sibling to `ProfileMappers.swift` and `ListMappers.swift` — the per-group +// slice of the audit-in-one-place mapper convention (PLAN.md §3). Owns the +// translation from `InterlinedKit.FollowCountsDTO` (a wire shape) to +// `FollowCounts` (the domain value the App layer renders). +// +// Per decision 0003 (App-layer Kit-import policy), the App layer no longer +// references the DTO directly — `SocialServicing.counts(of:)` returns +// `FollowCounts`, and this mapper is the one place that crosses the boundary. + +extension FollowCounts { + /// Maps the on-wire counts envelope to the domain value. The mapping is + /// total and lossless: the DTO has exactly these two fields. + public init(from dto: FollowCountsDTO) { + self.init(followers: dto.followerCount, following: dto.followingCount) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift index 5b78f40..f929ea0 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift @@ -54,12 +54,16 @@ extension UserProfile { /// Returns a copy with the counts populated — used by `SocialService` to /// stitch the identity payload together with the `/api/follow/[id]/counts` /// response without making the model mutable. - public func withCounts(_ counts: FollowCountsDTO) -> UserProfile { + /// + /// Takes the domain `FollowCounts` (not the underlying + /// `InterlinedKit.FollowCountsDTO`) per decision 0003 — App-layer call + /// sites must not need `import InterlinedKit`. + public func withCounts(_ counts: FollowCounts) -> UserProfile { UserProfile( summary: summary, bio: bio, - followerCount: counts.followerCount, - followingCount: counts.followingCount, + followerCount: counts.followers, + followingCount: counts.following, isPrivate: isPrivate, joinedAt: joinedAt ) diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift index 9edf4d5..06b10c9 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift @@ -29,6 +29,80 @@ public protocol MessagesServicing: Sendable { /// Loads the direct replies to a message. func replies(of id: String, limit: Int, offset: Int) async throws -> [Message] + + // MARK: - M2 write surface (PLAN.md §6 M2) + // + // Wraps the Wave 1 `InterlinedKit.Messages` builders. The write surface is + // deliberately small: only the fields the M2 composer / reply / repost / + // edit / delete UIs consume. Cross-post fan-out (`mastodonProviderIds`, + // `crossPostToBluesky`, `crossPostToLinkedIn`), scheduling (`scheduledAt`), + // and media attachments (`imageUrls`, `videoUrls`) are accepted by the + // kit's `CreateMessageRequest` but **not** exposed at the domain seam + // until M6, when the composer grows the platform pickers, the date + // picker, and the upload pipeline. Adding them later is additive. + + /// Creates a new message (post, reply, or repost depending on which of + /// `parentId` / `pushedMessageId` is set). + /// + /// - Parameters: + /// - body: the Markdown source — written into the API's `content` field. + /// - parentId: when set, the new message is a reply to this id. + /// - tags: hashtag-style tag tokens; empty for none. + /// - visibility: `.public` or `.private`. + /// - pushedMessageId: when set, the new message is a repost ("push") of + /// this id. Mutually exclusive in practice with `parentId` (a reply + /// to a repost is still a reply), but the API does not enforce + /// mutual exclusion and neither does this method — the caller's + /// intent flows through unchanged. + func create( + body: String, + parentId: String?, + tags: [String], + visibility: Visibility, + pushedMessageId: String? + ) async throws -> Message + + /// Convenience over `create` with `parentId` set. Lets the App-layer + /// reply UI call a verb that matches the intent ("reply to this") rather + /// than spelling out the create call. Visibility is explicit so the UI + /// can default a reply's visibility independently of the parent. + func reply( + to parentId: String, + body: String, + tags: [String], + visibility: Visibility + ) async throws -> Message + + /// Convenience over `create` with `pushedMessageId` set. The optional + /// `commentary` becomes the post body — `nil` and `""` both encode as an + /// empty body, which is what the web composer sends for a bare repost. + func repost( + _ pushedMessageId: String, + commentary: String?, + visibility: Visibility + ) async throws -> Message + + /// Edits an existing message in place. The full body/tags/visibility are + /// resent — this is a PUT, not a PATCH, matching the kit builder. + func update( + messageId: String, + body: String, + tags: [String], + visibility: Visibility + ) async throws -> Message + + /// Deletes a message. Removes it from the by-id cache on success so the + /// UI never re-renders a tombstoned message from the cache. + func delete(messageId: String) async throws + + /// Adds an "I Dig!" reaction to a message. Returns the updated cached + /// `Message` (with the new dig count and `didDig == true`) so the + /// optimistic-UI path can replace its in-memory copy. + func dig(messageId: String) async throws -> Message + + /// Removes an "I Dig!" reaction from a message. Mirrors `dig` — returns + /// the updated cached `Message`. + func undig(messageId: String) async throws -> Message } // MARK: - MessagesService @@ -130,6 +204,126 @@ public final class MessagesService: MessagesServicing { return messages } + // MARK: - M2 write surface (PLAN.md §6 M2) + + public func create( + body: String, + parentId: String?, + tags: [String], + visibility: Visibility, + pushedMessageId: String? + ) async throws -> Message { + // Encode an empty tag list as `nil` so the wire body stays minimal — + // `CreateMessageRequest.encode(to:)` skips nil fields. + let request = CreateMessageRequest( + content: body, + publiclyVisible: visibility.isPubliclyVisible, + tags: tags.isEmpty ? nil : tags, + parentId: parentId, + pushedMessageId: pushedMessageId + ) + let dto = try await api.send(Messages.create(request)) + let message = Message(from: dto) + await store?.upsert([message]) + return message + } + + public func reply( + to parentId: String, + body: String, + tags: [String], + visibility: Visibility + ) async throws -> Message { + try await create( + body: body, + parentId: parentId, + tags: tags, + visibility: visibility, + pushedMessageId: nil + ) + } + + public func repost( + _ pushedMessageId: String, + commentary: String?, + visibility: Visibility + ) async throws -> Message { + try await create( + body: commentary ?? "", + parentId: nil, + tags: [], + visibility: visibility, + pushedMessageId: pushedMessageId + ) + } + + public func update( + messageId: String, + body: String, + tags: [String], + visibility: Visibility + ) async throws -> Message { + let request = CreateMessageRequest( + content: body, + publiclyVisible: visibility.isPubliclyVisible, + tags: tags.isEmpty ? nil : tags + ) + let dto = try await api.send(Messages.update(id: messageId, request)) + let message = Message(from: dto) + await store?.upsert([message]) + return message + } + + public func delete(messageId: String) async throws { + try await api.sendVoid(Messages.delete(id: messageId)) + await store?.remove(id: messageId) + } + + public func dig(messageId: String) async throws -> Message { + let response = try await api.send(Messages.dig(id: messageId)) + return try await applyDigResponse(response, to: messageId) + } + + public func undig(messageId: String) async throws -> Message { + let response = try await api.send(Messages.undig(id: messageId)) + return try await applyDigResponse(response, to: messageId) + } + + /// Folds the small `DigResponse` envelope into a freshly-merged `Message`. + /// + /// Dig and undig do not return a full message body — only the updated + /// `digCount` / `dugByMe` pair (plus `isNewDig` / `digCreatedAt` on add, + /// which the domain does not consume). To return a usable `Message` for + /// the optimistic-UI path, we re-fetch the message body. This is one + /// extra round-trip per reaction, which we accept for M2 simplicity; + /// the call site can move to a purely-local merge once a cached + /// `Message` is guaranteed to be present (the optimistic flow already + /// has one in hand). The current shape is deliberately conservative. + private func applyDigResponse(_ response: DigResponse, to messageId: String) async throws -> Message { + let dto = try await api.send(Messages.get(id: messageId)) + // Trust the dig response's count/flag pair (it's the freshest), and + // overlay the freshly-fetched body so we have a complete `Message`. + let merged = Message(from: dto) + let updated = Message( + id: merged.id, + author: merged.author, + text: merged.text, + createdAt: merged.createdAt, + updatedAt: merged.updatedAt, + tags: merged.tags, + visibility: merged.visibility, + digCount: response.digCount, + didDig: response.dugByMe, + repostCount: merged.repostCount, + replyCount: merged.replyCount, + parentID: merged.parentID, + repost: merged.repost, + scheduledAt: merged.scheduledAt + ) + await store?.upsert([updated]) + return updated + } + // MARK: - Paginated consumption /// Mirrors the kit's `fetchPaginated` test helper and `ContractTests`: diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift index e37c332..b008801 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift @@ -81,7 +81,11 @@ public protocol SocialServicing: Sendable { /// Loads follower / following counts for `userId`. Cheap call that powers /// the header stats on a profile. - func counts(of userId: String) async throws -> FollowCountsDTO + /// + /// Returns the domain `FollowCounts` (not the underlying + /// `InterlinedKit.FollowCountsDTO`) per decision 0003 — App-layer files + /// must not need `import InterlinedKit` to render counts. + func counts(of userId: String) async throws -> FollowCounts /// Loads the followers list for `userId`. Bare-array shape today (see /// `FollowEndpoint.swift` note); the `UsersPage.hasMore` is always @@ -144,8 +148,9 @@ public final class SocialService: SocialServicing { try await api.send(Follow.status(userId: userId)) } - public func counts(of userId: String) async throws -> FollowCountsDTO { - try await api.send(Follow.counts(userId: userId)) + public func counts(of userId: String) async throws -> FollowCounts { + let dto = try await api.send(Follow.counts(userId: userId)) + return FollowCounts(from: dto) } // MARK: Follower / following lists diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/InMemoryMessageStoreTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/InMemoryMessageStoreTests.swift index e9f5af3..2180edf 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/InMemoryMessageStoreTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/InMemoryMessageStoreTests.swift @@ -55,6 +55,39 @@ final class InMemoryMessageStoreTests: XCTestCase { XCTAssertEqual(message?.text, "v2") } + func test_givenCachedMessage_whenRemoved_thenGoneFromByIdAndTimeline() async { + // Given a message that exists in both the by-id and the timeline + // index. + let store = InMemoryMessageStore() + await store.replaceTimeline( + [sampleMessage(id: "a"), sampleMessage(id: "b")], + scope: .all, + tag: nil + ) + + // When + await store.remove(id: "a") + + // Then + let removed = await store.cachedMessage(id: "a") + XCTAssertNil(removed) + let timeline = await store.cachedTimeline(scope: .all, tag: nil) + XCTAssertEqual(timeline.map(\.id), ["b"]) + } + + func test_givenMissingId_whenRemoved_thenNoOp() async { + // Given — boundary: removing an id that was never cached. + let store = InMemoryMessageStore() + await store.upsert([sampleMessage(id: "a")]) + + // When + await store.remove(id: "ghost") + + // Then — the existing message stays. + let kept = await store.cachedMessage(id: "a") + XCTAssertEqual(kept?.id, "a") + } + func test_givenPopulatedStore_whenCleared_thenEverythingGone() async { // Given let store = InMemoryMessageStore() diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift index cf0b4fe..b3264ff 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift @@ -113,6 +113,36 @@ final class MapperTests: XCTestCase { XCTAssertFalse(user.customerStatus.isSubscriber) } + // MARK: FollowCounts (decision 0003 — App-layer Kit-import policy) + + func test_givenCountsDTO_whenMapped_thenFollowersAndFollowingArePreserved() { + // Given + let dto = FollowCountsDTO(followerCount: 12, followingCount: 7) + + // When + let counts = FollowCounts(from: dto) + + // Then + XCTAssertEqual(counts.followers, 12) + XCTAssertEqual(counts.following, 7) + // And the DTO-shaped aliases continue to read the same values, so + // call sites that read `.followerCount` against the DTO compile + // unchanged against the domain value. + XCTAssertEqual(counts.followerCount, 12) + XCTAssertEqual(counts.followingCount, 7) + } + + func test_givenZeroedCountsDTO_whenMapped_thenMatchesZeroBoundary() { + // Given — boundary: a brand-new account with no follow relationships. + let dto = FollowCountsDTO(followerCount: 0, followingCount: 0) + + // When + let counts = FollowCounts(from: dto) + + // Then + XCTAssertEqual(counts, .zero) + } + // MARK: TimelinePage func test_givenPaginationHasMore_whenMapped_thenNextOffsetAdvances() { diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift index 5080519..02b857b 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceTests.swift @@ -280,6 +280,386 @@ final class MessagesServiceTests: XCTestCase { } } + // MARK: - M2 write surface: create + + func test_givenBody_whenCreating_thenPostsToMessagesAndReturnsDomainMessage() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-new", content: "hello")) + let store = InMemoryMessageStore() + let service = MessagesService(api: api, store: store) + + // When + let message = try await service.create( + body: "hello", + parentId: nil, + tags: ["swift"], + visibility: .public, + pushedMessageId: nil + ) + + // Then + XCTAssertEqual(message.id, "m-new") + XCTAssertEqual(message.text, "hello") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/messages") + let cached = await store.cachedMessage(id: "m-new") + XCTAssertEqual(cached?.id, "m-new") + } + + func test_givenEmptyBody_whenCreating_thenStillPostsAndReturnsServerResponse() async throws { + // Given — boundary: the API permits an empty content (bare reposts use + // this shape). The domain layer does not pre-validate; it forwards + // whatever the caller supplied and trusts the server to reject as + // needed. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-empty", content: "")) + let service = MessagesService(api: api) + + // When + let message = try await service.create( + body: "", + parentId: nil, + tags: [], + visibility: .public, + pushedMessageId: nil + ) + + // Then + XCTAssertEqual(message.id, "m-empty") + XCTAssertEqual(message.text, "") + } + + func test_givenAPIFailure_whenCreating_thenThrows() async throws { + // Given — invalid input: server rejects with 400. + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: "content too long")) + let service = MessagesService(api: api) + + // When / Then + do { + _ = try await service.create( + body: String(repeating: "x", count: 5_000), + parentId: nil, + tags: [], + visibility: .public, + pushedMessageId: nil + ) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "content too long")) + } + } + + func test_givenPrivateVisibility_whenCreating_thenStoresPrivateVisibility() async throws { + // Given — happy path covering visibility round-trip. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-priv", publiclyVisible: false)) + let service = MessagesService(api: api) + + // When + let message = try await service.create( + body: "secret", + parentId: nil, + tags: [], + visibility: .private, + pushedMessageId: nil + ) + + // Then + XCTAssertEqual(message.visibility, .private) + } + + // MARK: - M2 write surface: reply + + func test_givenParent_whenReplying_thenForwardsParentIdToCreate() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "r-1", parentId: "m-parent")) + let service = MessagesService(api: api) + + // When + let reply = try await service.reply( + to: "m-parent", + body: "great post", + tags: [], + visibility: .public + ) + + // Then + XCTAssertEqual(reply.parentID, "m-parent") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/messages") + XCTAssertEqual(recorded.first?.method, "POST") + } + + func test_givenEmptyParentId_whenReplying_thenStillForwardsToCreate() async throws { + // Given — invalid input case: empty parent. The domain layer doesn't + // pre-validate; it forwards to `create` and lets the server reject. + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: "missing parent")) + let service = MessagesService(api: api) + + // When / Then + do { + _ = try await service.reply(to: "", body: "hi", tags: [], visibility: .public) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "missing parent")) + } + } + + // MARK: - M2 write surface: repost + + func test_givenOriginal_whenReposting_thenSendsPushedMessageId() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "rp-1", pushedMessageId: "m-orig")) + let service = MessagesService(api: api) + + // When + let repost = try await service.repost( + "m-orig", + commentary: "this!", + visibility: .public + ) + + // Then + XCTAssertEqual(repost.id, "rp-1") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/messages") + } + + func test_givenNoCommentary_whenReposting_thenSendsEmptyBodyAndStillReturnsMessage() async throws { + // Given — boundary: a bare repost with no commentary. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "rp-2", content: "")) + let service = MessagesService(api: api) + + // When + let repost = try await service.repost("m-orig", commentary: nil, visibility: .public) + + // Then + XCTAssertEqual(repost.text, "") + } + + func test_givenRepostAPIFailure_whenReposting_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "blocked author")) + let service = MessagesService(api: api) + + // When / Then + do { + _ = try await service.repost("m-orig", commentary: nil, visibility: .public) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "blocked author")) + } + } + + // MARK: - M2 write surface: update (edit) + + func test_givenEdits_whenUpdating_thenPutsToMessageIdAndCachesResult() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-42", content: "edited")) + let store = InMemoryMessageStore() + let service = MessagesService(api: api, store: store) + + // When + let updated = try await service.update( + messageId: "m-42", + body: "edited", + tags: ["swift"], + visibility: .public + ) + + // Then + XCTAssertEqual(updated.text, "edited") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "PUT") + XCTAssertEqual(recorded.first?.path, "/api/messages/m-42") + let cached = await store.cachedMessage(id: "m-42") + XCTAssertEqual(cached?.text, "edited") + } + + func test_givenEmptyBody_whenUpdating_thenStillIssuesPut() async throws { + // Given — boundary: empty body. Forwarded as-is; server validates. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-42", content: "")) + let service = MessagesService(api: api) + + // When + let updated = try await service.update( + messageId: "m-42", + body: "", + tags: [], + visibility: .public + ) + + // Then + XCTAssertEqual(updated.text, "") + } + + func test_givenUpdateAPIFailure_whenUpdating_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "gone")) + let service = MessagesService(api: api) + + // When / Then + do { + _ = try await service.update( + messageId: "missing", + body: "x", + tags: [], + visibility: .public + ) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "gone")) + } + } + + // MARK: - M2 write surface: delete + + func test_givenMessageId_whenDeleting_thenIssuesDeleteAndRemovesFromCache() async throws { + // Given a primed cache that holds the message. + let store = InMemoryMessageStore() + await store.upsert([Message(from: sampleDTO(id: "m-del"))]) + let api = StubAPIClient() + // `sendVoid` consumes one queued outcome but discards the bytes — + // any non-failure entry works. + await api.enqueue(json: "{}") + let service = MessagesService(api: api, store: store) + + // When + try await service.delete(messageId: "m-del") + + // Then + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/messages/m-del") + let cached = await store.cachedMessage(id: "m-del") + XCTAssertNil(cached, "Deleted message must be evicted from the by-id cache.") + } + + func test_givenMessageNotCached_whenDeleting_thenStillSucceeds() async throws { + // Given — boundary: deleting a message we never cached. + let api = StubAPIClient() + await api.enqueue(json: "{}") + let service = MessagesService(api: api, store: InMemoryMessageStore()) + + // When / Then — no throw. + try await service.delete(messageId: "never-cached") + } + + func test_givenDeleteAPIFailure_whenDeleting_thenThrowsAndLeavesCacheIntact() async throws { + // Given a primed cache and a failing server delete. + let store = InMemoryMessageStore() + await store.upsert([Message(from: sampleDTO(id: "m-keep"))]) + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "not yours")) + let service = MessagesService(api: api, store: store) + + // When / Then + do { + try await service.delete(messageId: "m-keep") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "not yours")) + } + // Cache must remain untouched on a failed delete. + let cached = await store.cachedMessage(id: "m-keep") + XCTAssertNotNil(cached) + } + + // MARK: - M2 write surface: dig / undig + + func test_givenMessage_whenDigging_thenReturnsMessageWithUpdatedDigCount() async throws { + // Given — dig endpoint, followed by the get-by-id refresh the service + // issues to fold the new count into a full `Message`. + let api = StubAPIClient() + await api.enqueue(json: """ + { "digCount": 5, "dugByMe": true, "isNewDig": true } + """) + await api.enqueue(json: Fixtures.messageObject(id: "m-dig", digCount: 4, dugByMe: false)) + let store = InMemoryMessageStore() + let service = MessagesService(api: api, store: store) + + // When + let updated = try await service.dig(messageId: "m-dig") + + // Then — the dig response's count/flag win over the get-by-id payload. + XCTAssertEqual(updated.id, "m-dig") + XCTAssertEqual(updated.digCount, 5) + XCTAssertTrue(updated.didDig) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/messages/m-dig/dig") + let cached = await store.cachedMessage(id: "m-dig") + XCTAssertEqual(cached?.digCount, 5) + XCTAssertEqual(cached?.didDig, true) + } + + func test_givenAlreadyDug_whenUndigging_thenReturnsMessageWithDecrementedCount() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: """ + { "digCount": 3, "dugByMe": false } + """) + await api.enqueue(json: Fixtures.messageObject(id: "m-undig", digCount: 4, dugByMe: true)) + let service = MessagesService(api: api) + + // When + let updated = try await service.undig(messageId: "m-undig") + + // Then + XCTAssertEqual(updated.digCount, 3) + XCTAssertFalse(updated.didDig) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/messages/m-undig/dig") + } + + func test_givenDigAPIFailure_whenDigging_thenThrows() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(failure: .unauthorized(serverMessage: "sign in")) + let service = MessagesService(api: api) + + // When / Then + do { + _ = try await service.dig(messageId: "m-x") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .unauthorized(serverMessage: "sign in")) + } + } + + func test_givenDigSucceedsButRefreshFails_whenDigging_thenThrows() async throws { + // Given — boundary: dig POST succeeded, but the follow-up get-by-id + // refresh failed. The service surfaces the failure so the caller + // doesn't render a half-merged message. + let api = StubAPIClient() + await api.enqueue(json: """ + { "digCount": 9, "dugByMe": true, "isNewDig": true } + """) + await api.enqueue(failure: .transport(message: "offline")) + let service = MessagesService(api: api) + + // When / Then + do { + _ = try await service.dig(messageId: "m-x") + XCTFail("Expected an APIError from the refresh leg") + } catch let error as APIError { + XCTAssertEqual(error, .transport(message: "offline")) + } + } + // MARK: - Helpers private func sampleDTO(id: String) -> MessageDTO { diff --git a/docs/decisions/0003-kit-import-policy.md b/docs/decisions/0003-kit-import-policy.md new file mode 100644 index 0000000..541bcd1 --- /dev/null +++ b/docs/decisions/0003-kit-import-policy.md @@ -0,0 +1,123 @@ +# 0003 — App-layer Kit-import policy (explicit imports over re-export) + +- **Status:** Accepted (2026-06-22) +- **Date:** 2026-06-22 +- **Context:** PLAN.md §3 (Architecture / package boundaries — "DTOs never cross into the UI; domain models do"); `docs/progress.md` Wave 2 deviation #1 (latent kit-import compile failure surfaced when `SidebarDetailDispatcher` first routed `.profile` to `ProfileRootView`); decisions 0001 (auth transport) and 0002 (public-profile fallback). +- **Supersedes / superseded by:** — + +## Context + +Wave 2 wired the App layer's `ProfileRootView`, `ProfileHeaderView`, and +`ProfileViewModel` against `SocialServicing` from `InterlinedDomain`. These +view files also reference `FollowCountsDTO`, which lives in `InterlinedKit`. +While `.profile` was still routed to a placeholder in +`SidebarDetailDispatcher`, the App target never compiled the new files and +the missing `import InterlinedKit` went unnoticed. As soon as Wave 2 flipped +the dispatcher to `ProfileRootView()`, the build failed with "cannot find type +`FollowCountsDTO` in scope" in all three files. The recorded fix (add +`import InterlinedKit` to the leaf files) cleared the build, but +`docs/progress.md` Wave 2 deviation #1 flagged that the *policy* question — +"should App-layer files have to know which package owns each type?" — is +unresolved and needs a Wave 3 decision before any more App↔Domain wiring +ships. + +Two options were on the table: + +- **Option A — Explicit imports at every consumer.** App-layer files that + reference any `InterlinedKit.*` symbol must declare `import InterlinedKit` + themselves. `InterlinedDomain` re-exports nothing. +- **Option B — Re-export from Domain.** `InterlinedDomain` adds + `@_exported import InterlinedKit` so any consumer that imports Domain + transitively sees the entire `InterlinedKit` public surface. App files do + not need to know whether a given type lives in Domain or Kit. + +## Decision + +**Adopt Option A — explicit imports.** App-layer files that reference a Kit +symbol must declare `import InterlinedKit` themselves. `InterlinedDomain` +re-exports nothing. + +The simultaneous correction is **to remove the need for those imports +wherever a domain value can carry the data instead**: each App-layer file +that currently `import InterlinedKit` is treated as an open ticket to +introduce a domain model that hides the DTO. As of this decision, exactly +three App files had `import InterlinedKit` — all in `App/Features/Social/`, +all for `FollowCountsDTO`. They are converted in this same change to consume +a new `FollowCounts` domain value (see "Immediate refactor" below). + +## Rationale + +1. **PLAN.md §3 boundary.** The plan is explicit that "DTOs never cross into + the UI; domain models do." `@_exported import InterlinedKit` from Domain + would mechanically erase that boundary — every Kit DTO would be visible + from every App file with no friction. The Domain layer's job is to be + the App's vocabulary; re-exporting the wire types undermines that job in + the cheapest possible way. +2. **The Wave 2 deviation was a useful signal, not a usability bug.** The + compile failure on `FollowCountsDTO` was the type system telling us a + domain model was missing. Option B would have hidden the signal; Option A + surfaces it at the leaf, exactly where the missing-domain-model decision + has to be made. We *want* "I had to import Kit from a view" to feel + wrong, because it almost always means a `FollowCounts` / `Visibility` / + `MessageDraft` is missing from `InterlinedDomain`. +3. **Cost asymmetry.** Adding `import InterlinedKit` to a leaf file is a + one-line, IDE-autosuggested fix. Adopting `@_exported import` is a + permanent architectural change that affects every future App file + forever. The asymmetric cost of reversal weighs against B. +4. **Test-target hygiene.** Domain test targets already `import InterlinedKit` + directly (they need `APIError`, `MessageDTO`, etc. as test inputs). That + pattern is consistent across packages and is a well-established Swift + idiom; codifying the same rule for the App target keeps the project + uniform. +5. **`@_exported` is an underscored attribute.** `@_exported import` is a + Swift compiler internal that has no stability guarantee. Building the + project's public-import shape on top of an underscored feature is a small + but real risk we have no reason to take. + +## Consequences + +- **The new rule (enforced by convention, checked in code review).** Any + App-layer file that references a `InterlinedKit.*` symbol must declare + `import InterlinedKit` itself. `InterlinedDomain` declares no + `@_exported` imports. If a reviewer sees `import InterlinedKit` in a + `App/Features/**` file, that import is also a TODO to introduce a domain + model — the file should not be the long-term home of the Kit reference. +- **Immediate refactor (lands in this slice).** The three App files that + currently `import InterlinedKit` for `FollowCountsDTO` are converted to + consume a new `FollowCounts` domain value. A `FollowMappers.swift` in + `InterlinedDomain/Models/` maps `FollowCountsDTO → FollowCounts`. + `SocialServicing.counts(of:)` returns `FollowCounts`, not `FollowCountsDTO`. + After this change, no App-layer file in the repo imports `InterlinedKit` + for view rendering. +- **Test impact.** `SocialServiceTests.test_givenUserHasFollowers_…` and + `test_givenBrandNewUser_…` already assert on `.followerCount` / + `.followingCount` properties that exist on both DTO and domain value, + so the rename is type-only at those call sites. +- **No `@_exported import` anywhere.** This decision applies project-wide: + no package re-exports another package's public surface. + +## Follow-up actions + +- **Wave 5 (Social).** When the upstream `GET /api/users/[username]` + endpoint lands and decision 0002 is superseded, ensure the richer + `UserProfile` it returns continues to fold follower / following counts + through the `FollowCounts` domain value rather than re-introducing the + DTO at the view layer. +- **Wave-gate checklist.** Each wave's documentation update should grep + `App/**` for `import InterlinedKit` and treat any new hit as a deviation + to be recorded — and, by default, refactored away by introducing the + missing domain model. +- **`api-coverage.md`.** No coverage rows change for this decision (no + endpoints added or removed); the follow row counts that already flipped + in Wave 2 remain ☑. + +## Revisit triggers + +If any of the following occurs, this decision should be revisited: + +- More than three App-layer files end up needing to `import InterlinedKit` + in a single wave — the boilerplate cost has stopped being negligible, or + the Domain layer is systematically failing to cover the App's needs. +- A future Domain service exposes a DTO directly because translating it + would be lossy or impractical. That would be a smell worth recording + before it spreads. From 621a0621e164bf804e2b375e8a70f2962da46190 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 22 Jun 2026 20:03:33 -0700 Subject: [PATCH 03/16] Phase 3 completed. --- .claude/agents/doc-engineer.md | 12 + .claude/agents/swift-engineer.md | 51 +++- .claude/skills/doc-engineer/SKILL.md | 2 +- .../assets/docs-quality-checklist.md | 11 + .claude/skills/swift-engineer/SKILL.md | 8 +- .../assets/architecture-checklist.md | 10 + .../assets/bdd-test-template.md | 14 + .../assets/e2e-gate-checklist.md | 53 ++++ App/Composition/AppEnvironment.swift | 60 +++- App/Composition/ComposerEventBus.swift | 97 +++++++ App/Composition/CurrentUserStore.swift | 104 +++++++ .../Compose/ComposePlaceholderView.swift | 13 - App/Features/Compose/ComposerMode.swift | 46 ++++ App/Features/Compose/ComposerViewModel.swift | 162 +++++++++++ App/Features/Compose/ComposerWindowView.swift | 185 +++++++++++++ App/Features/Compose/RepostSheetView.swift | 146 ++++++++++ .../Compose/RepostSheetViewModel.swift | 87 ++++++ App/Features/Timeline/MessageDetailView.swift | 171 +++++++++++- .../Timeline/MessageDetailViewModel.swift | 200 +++++++++++++- App/Features/Timeline/MessageRowView.swift | 118 +++++++- App/Features/Timeline/TimelineRootView.swift | 85 +++++- App/Features/Timeline/TimelineViewModel.swift | 144 +++++++++- App/InterlinedListApp.swift | 33 ++- App/MenuCommands/ComposeCommands.swift | 40 +++ App/Navigation/MainWindowView.swift | 22 +- App/Resources/Info.plist | 4 + .../InterlinedList.help/Contents/Info.plist | 36 +++ .../en.lproj/InterlinedList.helpindex | Bin 0 -> 11919 bytes .../Resources/en.lproj/pgs/composing.html | 64 +++++ .../en.lproj/pgs/getting-started.html | 63 +++++ .../Resources/en.lproj/pgs/index.html | 55 ++++ .../Resources/en.lproj/pgs/lists-browse.html | 50 ++++ .../Resources/en.lproj/pgs/profile.html | 57 ++++ .../Resources/en.lproj/pgs/shortcuts.html | 53 ++++ .../Resources/en.lproj/pgs/timeline.html | 57 ++++ .../en.lproj/pgs/troubleshooting.html | 70 +++++ .../Contents/Resources/en.lproj/shrd/help.css | 143 ++++++++++ .../Contents/Resources/en.lproj/shrd/logo.png | Bin 0 -> 12088 bytes AppTests/ComposerViewModelTests.swift | 193 +++++++++++++ AppTests/CurrentUserStoreTests.swift | 95 +++++++ AppTests/MessageDetailViewModelTests.swift | 229 ++++++++++++++++ AppTests/RepostSheetViewModelTests.swift | 120 ++++++++ AppTests/Support/MessageFixtures.swift | 72 +++++ AppTests/Support/StubMessagesService.swift | 187 +++++++++++++ AppTests/Support/StubSessionManaging.swift | 77 ++++++ AppTests/TimelineViewModelTests.swift | 257 ++++++++++++++++++ InterlinedList.xcodeproj/project.pbxproj | 144 ++++++++++ .../xcschemes/InterlinedList.xcscheme | 24 ++ docs/api-coverage.md | 9 +- docs/progress.md | 88 ++++++ docs/user/README.md | 19 ++ docs/user/feature-status.md | 27 ++ docs/user/getting-started.md | 41 +++ 53 files changed, 4036 insertions(+), 72 deletions(-) create mode 100644 .claude/skills/swift-engineer/assets/e2e-gate-checklist.md create mode 100644 App/Composition/ComposerEventBus.swift create mode 100644 App/Composition/CurrentUserStore.swift delete mode 100644 App/Features/Compose/ComposePlaceholderView.swift create mode 100644 App/Features/Compose/ComposerMode.swift create mode 100644 App/Features/Compose/ComposerViewModel.swift create mode 100644 App/Features/Compose/ComposerWindowView.swift create mode 100644 App/Features/Compose/RepostSheetView.swift create mode 100644 App/Features/Compose/RepostSheetViewModel.swift create mode 100644 App/MenuCommands/ComposeCommands.swift create mode 100644 App/Resources/InterlinedList.help/Contents/Info.plist create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/InterlinedList.helpindex create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/composing.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/index.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/lists-browse.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/profile.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/shortcuts.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/timeline.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/shrd/help.css create mode 100644 App/Resources/InterlinedList.help/Contents/Resources/en.lproj/shrd/logo.png create mode 100644 AppTests/ComposerViewModelTests.swift create mode 100644 AppTests/CurrentUserStoreTests.swift create mode 100644 AppTests/MessageDetailViewModelTests.swift create mode 100644 AppTests/RepostSheetViewModelTests.swift create mode 100644 AppTests/Support/MessageFixtures.swift create mode 100644 AppTests/Support/StubMessagesService.swift create mode 100644 AppTests/Support/StubSessionManaging.swift create mode 100644 AppTests/TimelineViewModelTests.swift create mode 100644 docs/user/README.md create mode 100644 docs/user/feature-status.md create mode 100644 docs/user/getting-started.md diff --git a/.claude/agents/doc-engineer.md b/.claude/agents/doc-engineer.md index e491f3a..68b7db0 100644 --- a/.claude/agents/doc-engineer.md +++ b/.claude/agents/doc-engineer.md @@ -28,6 +28,18 @@ You are the InterlinedList Documentation Engineer. Your job is to produce accura - Write concisely and actionably; state assumptions explicitly. - Every output must identify: objective, audience track, docs created or updated, validation notes, and open questions. +## Project-specific rules (proven by past waves) + +- **Help Book and `docs/user/` stay in sync.** `docs/user/.md` is the source of truth; the matching `.help/Contents/Resources/.lproj/pgs/.html` mirrors its wording. Divergence is a maintenance bug — flag it. +- **Shipped vs. planned discipline.** User-facing pages may only describe shipped behavior (anchored in `docs/progress.md`). Planned features are labeled "coming in a future update" with a brief explanation. Cross-check the progress log before claiming a feature works. +- **Apple Help Book layout.** `App/Resources/InterlinedList.help/Contents/{Info.plist, Resources/.lproj/{InterlinedList.helpindex, pgs/*.html, shrd/*}}`. Bundle `Info.plist` keys: `CFBundlePackageType=BNDL`, `CFBundleSignature=hbwr`, `HPDBookTitle`, `HPDBookType=3`, `HPDBookAccessPath=pgs/index.html`, `HPDBookIconPath`, `HPDBookIndexPath`. App `Info.plist` keys: `CFBundleHelpBookFolder` (folder name) + `CFBundleHelpBookName` (matches the help bundle's `CFBundleIdentifier`). +- **No `