diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index f5cad25..f5fdd50 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -94,6 +94,15 @@ final class AppEnvironment: ObservableObject { /// doubles substitute in. let documentsService: DocumentsServicing + /// The server-side document-templates surface (the-gaps.md G12) — the + /// user's own saved template documents. Distinct from the client-side + /// `DocumentTemplate.builtIn` catalog: this lists / creates-from / seeds + /// server templates so the "New from Template" picker can show a + /// "Your templates" section. Exposed as the protocol so test doubles + /// substitute in. Ungated, so it is a plain stored service (no live + /// entitlements rebuild). + let documentTemplatesService: DocumentTemplatesServicing + /// The owner of `/api/documents/sync` — the M4 offline backbone /// (PLAN.md §3, §6 M4). The App layer reaches in directly for the /// `syncNow()` button on the toolbar; the rest of the App talks to @@ -150,6 +159,71 @@ final class AppEnvironment: ObservableObject { /// endpoints and returns domain `CSVExport` values. let exportsService: ExportsServicing + /// The global-search surface the Search feature binds against + /// (the-gaps.md G5). Exposed as the protocol so test doubles + /// substitute in. Fans out over messages / lists / documents and + /// returns domain values. + let search: SearchServicing + + /// The moderation surface the Blocked & Muted settings pane and the + /// per-user / per-message report affordances bind against + /// (the-gaps.md G2). Exposed as the protocol so test doubles + /// substitute in. + let moderation: ModerationServicing + + /// The Direct Messages surface the Messages feature binds against + /// (the-gaps.md G1). Exposed as the protocol so test doubles + /// substitute in. Wraps the `/api/messages/*` DM endpoints (folders, + /// thread, send, recipients, unread, read/trash/restore) and returns + /// domain `DirectMessage` / `DMThread` / `DMPage` values. + let directMessages: DirectMessagesServicing + + /// Cross-window event bus for the Direct Messages feature (the-gaps.md + /// G1). Sending / reading / trashing posts to this bus so the DM list, + /// an open thread, and the unread-badge coordinator update in place + /// without a refetch. + let directMessagesEventBus: DirectMessagesEventBus + + /// Live list-folders surface (the-gaps.md G6). Folders are + /// subscriber-gated on create, so the service is rebuilt on each + /// access with the current account's entitlements — mirroring + /// `liveEntitlements`, so a mid-session subscription change re-gates + /// folder creation without stale state. The read / rename / move / + /// delete paths are ungated and unaffected. + var listFolders: ListFoldersServicing { + ListFoldersService( + api: listFoldersAPI, + entitlements: EntitlementsService(user: currentUserStore.currentUser) + ) + } + + /// The shared kit-layer API client retained so `listFolders` can + /// rebuild the folders service with live entitlements on each access. + private let listFoldersAPI: APIClientProtocol + + /// Live share-links surface (the-gaps.md G3). Creating a link is + /// subscriber-gated, so — exactly like `listFolders` — the service is + /// rebuilt on each access with the current account's entitlements, so a + /// mid-session subscription change re-gates link creation without stale + /// state. The list / resolve / revoke / claim paths are ungated and + /// unaffected. + var sharing: SharingServicing { + SharingService( + api: sharingAPI, + entitlements: EntitlementsService(user: currentUserStore.currentUser) + ) + } + + /// The shared kit-layer API client retained so `sharing` can rebuild the + /// sharing service with live entitlements on each access. + private let sharingAPI: APIClientProtocol + + /// Base URL used to compose canonical web share URLs (`…/lists/shared/…`) + /// when the server does not return a pre-built `ShareLink.url`. Defaults + /// to the production host; the App layer reads it through the domain-only + /// `URL` type so no kit import leaks into the sharing views. + let shareBaseURL: URL + /// Designated initializer used by tests and previews that want to /// inject a fully synthetic service graph. Production code calls /// `live()` instead. @@ -163,6 +237,7 @@ final class AppEnvironment: ObservableObject { listsEventBus: ListsEventBus, listsStore: ListsStore, documentsService: DocumentsServicing, + documentTemplatesService: DocumentTemplatesServicing, documentSyncEngine: DocumentSyncEngine, documentSyncEvents: AsyncStream, notificationsService: NotificationsServicing, @@ -171,7 +246,14 @@ final class AppEnvironment: ObservableObject { followRelationshipReader: FollowRelationshipReading, orgService: OrgServicing, userService: UserServicing, - exportsService: ExportsServicing + exportsService: ExportsServicing, + search: SearchServicing, + moderation: ModerationServicing, + directMessages: DirectMessagesServicing, + directMessagesEventBus: DirectMessagesEventBus, + listFoldersAPI: APIClientProtocol, + sharingAPI: APIClientProtocol, + shareBaseURL: URL ) { self.messages = messages self.lists = lists @@ -182,6 +264,7 @@ final class AppEnvironment: ObservableObject { self.listsEventBus = listsEventBus self.listsStore = listsStore self.documentsService = documentsService + self.documentTemplatesService = documentTemplatesService self.documentSyncEngine = documentSyncEngine self.documentSyncEvents = documentSyncEvents self.notificationsService = notificationsService @@ -191,6 +274,13 @@ final class AppEnvironment: ObservableObject { self.orgService = orgService self.userService = userService self.exportsService = exportsService + self.search = search + self.moderation = moderation + self.directMessages = directMessages + self.directMessagesEventBus = directMessagesEventBus + self.listFoldersAPI = listFoldersAPI + self.sharingAPI = sharingAPI + self.shareBaseURL = shareBaseURL } /// Builds the production service graph: @@ -284,6 +374,11 @@ final class AppEnvironment: ObservableObject { api: api, sync: documentSyncEngine ) + // Server document templates (the-gaps.md G12). Reuses the same + // kit-layer `APIClient` like the other services do — the + // `/api/documents/templates` endpoints are already routed by the + // shared `authTransport`. Ungated, so a plain stored service. + let documentTemplatesService = DocumentTemplatesService(api: api) let documentSyncEvents = documentSyncEngine.events // M5 — Notifications + Social write surface (PLAN.md §6 M5). // `NotificationsService` already exists with the read + mark @@ -307,6 +402,24 @@ final class AppEnvironment: ObservableObject { // decision-0001 session-only allowlist (`/api/exports/*`), already // routed by the shared `authTransport`. let exportsService = ExportsService(api: api) + // Web-parity batch (the-gaps.md G5 / G2 / G6). All three reuse the + // same kit-layer `APIClient` like `lists` / `social` do — their + // endpoints are already routed by the shared `authTransport`. + // • Search — full-text over messages / lists / documents (G5). + // • Moderation — blocks / mutes / reports (G2). + // • List folders — the API client is retained on the environment + // so `listFolders` can rebuild the service with live + // entitlements per access (folders are subscriber-gated on + // create, G6). + let search = SearchService(api: api) + let moderation = ModerationService(api: api) + // Direct Messages (the-gaps.md G1). Reuses the same kit-layer + // `APIClient` like `lists` / `social` / `search` do — the DM + // endpoints are already routed by the shared `authTransport`. The + // event bus is a singleton so the DM list, an open thread, and the + // dock-badge coordinator all see the same stream. + let directMessages = DirectMessagesService(api: api) + let directMessagesEventBus = DirectMessagesEventBus() return AppEnvironment( messages: messages, lists: lists, @@ -317,6 +430,7 @@ final class AppEnvironment: ObservableObject { listsEventBus: listsEventBus, listsStore: listsStore, documentsService: documentsService, + documentTemplatesService: documentTemplatesService, documentSyncEngine: documentSyncEngine, documentSyncEvents: documentSyncEvents, notificationsService: notificationsService, @@ -325,7 +439,20 @@ final class AppEnvironment: ObservableObject { followRelationshipReader: followRelationshipReader, orgService: orgService, userService: userService, - exportsService: exportsService + exportsService: exportsService, + search: search, + moderation: moderation, + directMessages: directMessages, + directMessagesEventBus: directMessagesEventBus, + listFoldersAPI: api, + // Share Links (the-gaps.md G3) reuse the same kit-layer + // `APIClient`; the API client is retained on the environment so + // `sharing` can rebuild the service with live entitlements per + // access (link creation is subscriber-gated). The base URL feeds + // the canonical web-URL builder for links the server returns + // without a pre-built `url`. + sharingAPI: api, + shareBaseURL: InterlinedKit.defaultBaseURL ) } diff --git a/App/Composition/DirectMessagesEventBus.swift b/App/Composition/DirectMessagesEventBus.swift new file mode 100644 index 0000000..fda7263 --- /dev/null +++ b/App/Composition/DirectMessagesEventBus.swift @@ -0,0 +1,88 @@ +// DirectMessagesEventBus +// +// Cross-window pub/sub bus for the Direct Messages feature (the-gaps.md +// G1). Mirrors `NotificationsEventBus` / `ComposerEventBus`: an internal +// actor holds the live continuations keyed by UUID; `events()` returns +// an `AsyncStream` per subscriber. +// +// The bus lets the DM list, an open thread, the composer sheet, and the +// unread-badge coordinator react in place to writes performed by other +// windows / menu commands without forcing a full refetch: +// - sending a message updates the sender's conversation-list preview, +// - reading a thread decrements the unread pip, +// - a fresh `unreadCount()` read republishes the authoritative total. +// +// Decision 0003 compliance: this file lives in `App/Composition/` and +// consumes only `InterlinedDomain`; no kit symbol crosses the boundary. + +import Foundation +import InterlinedDomain + +/// One event a Direct Messages surface emits after a successful write / +/// read. Subscribers translate these into pure local mutations (a +/// conversation-list preview swap, an unread-pip decrement) or, for the +/// badge coordinator, into a dock-badge write. +enum DirectMessagesEvent: Sendable, Equatable { + + /// A fresh `unreadCount()` read landed. The badge aggregator writes + /// this as the DM contribution to the dock badge; a sidebar pip binds + /// to it too. + case unreadCountChanged(Int) + + /// A message was sent to `recipientUsername`. Open list / thread + /// surfaces for that conversation append it in place. + case messageSent(recipientUsername: String, message: DirectMessage) + + /// A thread with `username` was opened and its inbound messages + /// marked read. Peer surfaces drop that conversation's unread pip. + case threadRead(username: String) +} + +/// Shared event bus for the Direct Messages feature. Use `events()` for +/// a subscription stream; terminate by cancelling the consuming task. +final class DirectMessagesEventBus: Sendable { + + private let storage = Storage() + + init() {} + + /// Returns an `AsyncStream` that yields every event posted after + /// subscription. The stream finishes when the consumer cancels. + func events() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + Task { await self.storage.register(id: id, continuation: continuation) } + continuation.onTermination = { _ in + Task { await self.storage.unregister(id: id) } + } + } + } + + /// Publish an event to every active subscriber. Late subscribers do + /// not receive past events. + func post(_ event: DirectMessagesEvent) { + Task { await storage.broadcast(event) } + } + + // MARK: - Storage + + /// Holds the live continuations keyed by registration UUID. An actor + /// because publishers and subscribers aren't serialized. + private actor Storage { + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + func register(id: UUID, continuation: AsyncStream.Continuation) { + continuations[id] = continuation + } + + func unregister(id: UUID) { + continuations[id] = nil + } + + func broadcast(_ event: DirectMessagesEvent) { + for continuation in continuations.values { + continuation.yield(event) + } + } + } +} diff --git a/App/Composition/DirectMessagesUnreadBadgeCoordinator.swift b/App/Composition/DirectMessagesUnreadBadgeCoordinator.swift new file mode 100644 index 0000000..151f374 --- /dev/null +++ b/App/Composition/DirectMessagesUnreadBadgeCoordinator.swift @@ -0,0 +1,99 @@ +// DirectMessagesUnreadBadgeCoordinator +// +// Owns the dock-badge subscription glue for the Direct Messages feature +// (the-gaps.md G1). Mirrors `NotificationsUnreadBadgeCoordinator`: it +// listens on `DirectMessagesEventBus` and translates each event into the +// DM contribution to the shared dock badge. +// +// Unlike the notifications coordinator (which predates the aggregator and +// still writes a raw count through its own closure), this coordinator was +// born after `UnreadBadgeAggregator` existed, so it reports into the +// `.directMessages` slot of the aggregator via the injected closure. The +// aggregator sums the DM and notifications slots and performs the single +// actual badge write. This keeps DMs from clobbering the notifications +// contribution and vice-versa. +// +// The DM list view model posts `unreadCountChanged(...)` after every +// `unreadCount()` read and after a thread is opened / read; the +// coordinator translates those into the current DM unread total. +// +// Decision 0003 compliance: lives in `App/Composition/` and imports only +// `Foundation` and `InterlinedDomain`; the AppKit reach is hidden behind +// the `@MainActor` closure the composition root supplies. + +import Foundation +import InterlinedDomain + +/// Drives the DM contribution of the dock-tile badge from +/// `DirectMessagesEventBus` events. Minimal by design — every business +/// rule lives in the DM view models; the coordinator only folds events +/// into a count and reports it upward. +final class DirectMessagesUnreadBadgeCoordinator: @unchecked Sendable { + + /// Reports the DM unread count into the shared badge aggregator. + /// Injected so tests record without AppKit. + private let reportCount: @MainActor @Sendable (Int) -> Void + + /// The event bus the coordinator subscribes to. + private let bus: DirectMessagesEventBus + + /// Best-effort tracker of the last authoritative DM unread count so a + /// `threadRead` event can decrement without a fresh `unreadCount()` + /// round-trip. `nil` until the first `unreadCountChanged` arrives. + private var lastKnownUnread: Int? + + /// Subscription task; `nil` until `start()` is called. + private var subscription: Task? + + init( + bus: DirectMessagesEventBus, + reportCount: @escaping @MainActor @Sendable (Int) -> Void + ) { + self.bus = bus + self.reportCount = reportCount + } + + /// Begins consuming the event stream. Safe to call multiple times — + /// re-subscribing replaces the prior task. + func start() { + subscription?.cancel() + let stream = bus.events() + let reportCount = self.reportCount + subscription = Task { [weak self] in + for await event in stream { + guard let self else { return } + let count = await self.fold(event: event) + await MainActor.run { reportCount(count) } + } + } + } + + /// Stops consuming events. Idempotent. + func stop() { + subscription?.cancel() + subscription = nil + } + + /// Visible for tests — fold an event into the next DM unread count. + /// Returns the value the coordinator would have reported for `event`. + /// Pure: no AppKit, no I/O. + func fold(event: DirectMessagesEvent) async -> Int { + switch event { + case .unreadCountChanged(let count): + let clamped = max(0, count) + lastKnownUnread = clamped + return clamped + case .threadRead: + // Opening a thread clears that conversation's unread inbound + // messages; without a per-conversation count we conservatively + // leave the known total in place and let the next + // `unreadCountChanged` (posted right after mark-read) supply + // the authoritative value. Reporting the last-known avoids a + // flicker to a wrong number. + return lastKnownUnread ?? 0 + case .messageSent: + // Sending a message never changes *your own* unread count. + return lastKnownUnread ?? 0 + } + } +} diff --git a/App/Composition/UnreadBadgeAggregator.swift b/App/Composition/UnreadBadgeAggregator.swift new file mode 100644 index 0000000..e170778 --- /dev/null +++ b/App/Composition/UnreadBadgeAggregator.swift @@ -0,0 +1,64 @@ +// UnreadBadgeAggregator +// +// Single owner of the macOS dock-tile badge label (the-gaps.md G1). +// +// Before Direct Messages, the dock badge was written directly by +// `NotificationsUnreadBadgeCoordinator` from the notifications-unread +// count. DMs add a *second* independent unread source, and the dock has +// exactly one badge, so the two counts must be summed by a single writer +// — otherwise whichever coordinator wrote last would clobber the other's +// contribution. +// +// This aggregator holds the latest count from each named source and, on +// every update, writes the sum through the injected dock-badge closure +// (the one `@MainActor` AppKit reach, provided by the composition root +// so tests record without AppKit). Both the notifications coordinator and +// the DM badge coordinator now report *into* the aggregator instead of +// writing the badge themselves. +// +// Deviation note (reported to the user): this changes how the dock badge +// aggregates. Notifications no longer own the badge label outright — they +// own the `.notifications` slot of a summed total. The notifications count +// alone is unchanged; the badge simply also reflects DM unread now. +// +// Decision 0003 compliance: lives in `App/Composition/` (allowed to cross +// layers) and imports only `Foundation`. The AppKit dependency is hidden +// behind a `@MainActor` closure. + +import Foundation + +/// Sums per-source unread counts and writes the total to the dock badge. +/// Thread-confined to the main actor because the underlying +/// `NSApp.dockTile` write requires it and the source counts are small. +@MainActor +final class UnreadBadgeAggregator { + + /// The named unread sources contributing to the single dock badge. + enum Source: String, Sendable, CaseIterable { + case notifications + case directMessages + } + + /// The dock-badge writer, injected so tests record without AppKit. + private let writeBadge: @MainActor @Sendable (Int) -> Void + + /// Latest count per source. Absent sources contribute zero. + private var counts: [Source: Int] = [:] + + init(writeBadge: @escaping @MainActor @Sendable (Int) -> Void) { + self.writeBadge = writeBadge + } + + /// Records `count` for `source` and writes the new summed total to + /// the badge. Negative inputs are clamped to zero defensively. + func update(source: Source, count: Int) { + counts[source] = max(0, count) + writeBadge(total) + } + + /// The current summed unread total across all sources. Exposed for + /// tests and for a sidebar aggregate pip. + var total: Int { + counts.values.reduce(0, +) + } +} diff --git a/App/Features/Compose/ComposerViewModel.swift b/App/Features/Compose/ComposerViewModel.swift index 6d47628..3423799 100644 --- a/App/Features/Compose/ComposerViewModel.swift +++ b/App/Features/Compose/ComposerViewModel.swift @@ -91,6 +91,11 @@ final class ComposerViewModel { var mastodonProviderIdsInput: String = "" var crossPostToBluesky: Bool = false var crossPostToLinkedIn: Bool = false + /// X / Twitter cross-post target (G7). A plain boolean the API takes + /// directly, mirroring `crossPostToLinkedIn` exactly (no async readiness + /// gate — LinkedIn has none either; the NW-4 readiness check is only wired + /// for Bluesky and Mastodon). + var crossPostToTwitter: Bool = false // MARK: - Read-only state @@ -293,7 +298,8 @@ final class ComposerViewModel { scheduledAt: scheduled, mastodonProviderIds: mastodonProviderIds, crossPostToBluesky: crossPostToBluesky, - crossPostToLinkedIn: crossPostToLinkedIn + crossPostToLinkedIn: crossPostToLinkedIn, + crossPostToTwitter: crossPostToTwitter ) eventBus.post(.messageCreated(created)) if !created.crossPostResults.isEmpty { diff --git a/App/Features/Compose/ComposerWindowView.swift b/App/Features/Compose/ComposerWindowView.swift index 79f0538..a0ba5ba 100644 --- a/App/Features/Compose/ComposerWindowView.swift +++ b/App/Features/Compose/ComposerWindowView.swift @@ -337,6 +337,15 @@ struct ComposerWindowView: View { set: { viewModel.crossPostToLinkedIn = $0 } )) { Text("LinkedIn") } .disabled(!viewModel.canUseSubscriberFeatures) + + // G7: X / Twitter cross-post toggle. Mirrors LinkedIn exactly — a + // plain boolean binding (no async readiness check; only Bluesky and + // Mastodon have the NW-4 configured-check). + Toggle(isOn: Binding( + get: { viewModel.crossPostToTwitter }, + set: { viewModel.crossPostToTwitter = $0 } + )) { Text("X") } + .disabled(!viewModel.canUseSubscriberFeatures) } .help(viewModel.canUseSubscriberFeatures ? "Also publish to your linked social accounts." diff --git a/App/Features/DirectMessages/DMAvatarView.swift b/App/Features/DirectMessages/DMAvatarView.swift new file mode 100644 index 0000000..93ff6df --- /dev/null +++ b/App/Features/DirectMessages/DMAvatarView.swift @@ -0,0 +1,33 @@ +// DMAvatarView +// +// Small circular avatar used across the Direct Messages surfaces (the- +// gaps.md G1) — conversation rows, the thread header, and the recipient +// picker. Mirrors the `AsyncImage` + SF-Symbol fallback pattern used by +// `BlockedAndMutedView` / `ProfileHeaderView` so DM avatars look identical +// to their home surfaces. Pure presentation. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct DMAvatarView: View { + let user: UserSummary? + var size: CGFloat = 32 + + var body: some View { + AsyncImage(url: user?.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: size, height: size) + .clipShape(Circle()) + .accessibilityHidden(true) + } +} diff --git a/App/Features/DirectMessages/DMThreadView.swift b/App/Features/DirectMessages/DMThreadView.swift new file mode 100644 index 0000000..0187bd0 --- /dev/null +++ b/App/Features/DirectMessages/DMThreadView.swift @@ -0,0 +1,221 @@ +// DMThreadView +// +// The thread column of `DirectMessagesRootView` (the-gaps.md G1): a +// resolved 1:1 conversation. Renders the message bubbles (aligned by +// `isOutgoing`), a live-polling load driven by `.task`/`.onDisappear`, +// and a composer bar. A thin shell over `DMThreadViewModel` — all load / +// send / poll logic lives in the view model so unit tests cover it +// without SwiftUI. +// +// The view constructs its own `DMThreadViewModel` in `.task` from the +// environment (SwiftUI doesn't expose `@Environment` at init), keyed on +// `username`. When `username` changes (the user picks a different +// conversation) the view is re-identified by the caller via `.id(...)`, +// so a fresh view model + poll are created and the old poll is torn down. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct DMThreadView: View { + + let username: String + /// Current user id provider, threaded from the root so bubbles align. + let currentUserID: @MainActor () -> String? + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: DMThreadViewModel? + + var body: some View { + Group { + if let viewModel { + threadBody(viewModel: viewModel) + } else { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task { + if viewModel == nil, let environment { + let vm = DMThreadViewModel( + username: username, + service: environment.directMessages, + eventBus: environment.directMessagesEventBus, + currentUserID: currentUserID + ) + viewModel = vm + await vm.startPolling() + } + } + .onDisappear { + viewModel?.stopPolling() + } + } + + // MARK: - Body + + @ViewBuilder + private func threadBody(viewModel: DMThreadViewModel) -> some View { + VStack(spacing: 0) { + header(viewModel: viewModel) + Divider() + transcript(viewModel: viewModel) + Divider() + composer(viewModel: viewModel) + } + } + + @ViewBuilder + private func header(viewModel: DMThreadViewModel) -> some View { + HStack(spacing: 10) { + DMAvatarView(user: viewModel.otherUser, size: 32) + VStack(alignment: .leading, spacing: 1) { + Text(viewModel.otherUser?.displayName ?? "@\(username)") + .font(.body.weight(.semibold)) + .lineLimit(1) + Text("@\(viewModel.otherUser?.username ?? username)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if viewModel.isBlocked { + Label("Blocked", systemImage: "hand.raised") + .font(.ilSubtitle()) + .foregroundStyle(.red) + } else if !viewModel.isMutual, viewModel.hasLoadedOnce { + Label("Not mutual", systemImage: "person.crop.circle.badge.xmark") + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private func transcript(viewModel: DMThreadViewModel) -> some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.messages) { message in + bubble(message: message) + .id(message.id) + } + } + .padding(16) + } + .onChange(of: viewModel.messages.count) { + if let last = viewModel.messages.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + .overlay { + if viewModel.isLoading, !viewModel.hasLoadedOnce { + ProgressView("Loading conversation…") + } else if viewModel.messages.isEmpty, viewModel.hasLoadedOnce { + emptyState + } + } + } + + @ViewBuilder + private func bubble(message: DirectMessage) -> some View { + let me = currentUserID() + let outgoing = me.map { message.isOutgoing(currentUserId: $0) } ?? false + HStack { + if outgoing { Spacer(minLength: 40) } + VStack(alignment: outgoing ? .trailing : .leading, spacing: 4) { + if !message.body.isEmpty { + Text(message.body) + .font(.ilBody()) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(outgoing ? Color.accentColor.opacity(0.85) : Color.secondary.opacity(0.15)) + ) + .foregroundStyle(outgoing ? Color.white : Color.primary) + .fixedSize(horizontal: false, vertical: true) + } + Text(Self.timeFormatter.localizedString(for: message.createdAt, relativeTo: .now)) + .font(.ilMono(9)) + .foregroundStyle(.secondary) + } + if !outgoing { Spacer(minLength: 40) } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(outgoing ? "You said" : "They said"): \(message.body)") + } + + @ViewBuilder + private func composer(viewModel: DMThreadViewModel) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let error = viewModel.error { + Text(error.localizedDescription) + .font(.ilSubtitle()) + .foregroundStyle(.red) + } + HStack(spacing: 8) { + TextField( + composerPlaceholder(viewModel: viewModel), + text: Binding( + get: { viewModel.draft }, + set: { viewModel.draft = $0 } + ), + axis: .vertical + ) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + .disabled(!viewModel.isMutual || viewModel.isBlocked) + .onSubmit { + Task { await viewModel.send() } + } + .accessibilityLabel("Message text") + + Button { + Task { await viewModel.send() } + } label: { + if viewModel.isSending { + ProgressView().controlSize(.small) + } else { + Image(systemName: "paperplane.fill") + } + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canSend || viewModel.isSending) + .accessibilityLabel("Send message") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func composerPlaceholder(viewModel: DMThreadViewModel) -> String { + if viewModel.isBlocked { return "You can't message this account" } + if !viewModel.isMutual, viewModel.hasLoadedOnce { + return "You can only message mutual followers" + } + return "Message @\(viewModel.otherUser?.username ?? username)" + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.ilDisplay(32)) + .foregroundStyle(.secondary) + Text("No messages yet") + .font(.ilSubtitle()) + Text("Say hello.") + .foregroundStyle(.secondary) + } + } + + private static let timeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() +} diff --git a/App/Features/DirectMessages/DMThreadViewModel.swift b/App/Features/DirectMessages/DMThreadViewModel.swift new file mode 100644 index 0000000..91b3143 --- /dev/null +++ b/App/Features/DirectMessages/DMThreadViewModel.swift @@ -0,0 +1,418 @@ +// DMThreadViewModel +// +// Drives the thread column of `DirectMessagesRootView` (the-gaps.md G1) — +// a resolved 1:1 conversation with one other user. Owns the rendered +// message list, the composer draft, the send action, the mark-read-on- +// open behaviour, and the live `threadUpdates` poll. Reads through +// `DirectMessagesServicing` only so unit tests substitute a stub service. +// +// Concurrency ownership: +// - `startPolling()` (bound to the view's `.task` / `.onAppear`) +// performs the initial `thread(...)` load, marks inbound messages +// read, then loops `threadUpdates(...)` every `pollInterval`, merging +// new messages in place. The loop honours `Task.isCancelled`. +// - `stopPolling()` (bound to `.onDisappear`) cancels the loop task. +// The poll task is owned by this view model, captured `[weak self]`, +// and never relies on `deinit`-time cancellation (Observation-macro +// semantics — mirrors `CurrentUserStore` / `SearchViewModel`). +// +// Optimistic send (per the swift-engineer skill): a blank draft (no text, +// no images) is rejected before the service is touched. On send we append +// an optimistic placeholder keyed by a temporary id, call the service, +// and on success replace the placeholder with the server's authoritative +// `DirectMessage` (never trusting the local copy). On failure we remove +// the placeholder, restore the draft, and surface the error. +// +// Bubble alignment uses `DirectMessage.isOutgoing(currentUserId:)` with +// the id from the injected `currentUserID` closure so the view model +// always sees the latest session (mirrors `ProfileViewModel`). +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class DMThreadViewModel { + + // MARK: - Configuration + + /// How often the visible thread polls `threadUpdates`. Tests inject a + /// tiny interval (or drive updates manually) so they don't wait 5s. + static let defaultPollInterval: Duration = .seconds(6) + + // MARK: - Subject + + /// The other participant's username — the thread key. + let username: String + + // MARK: - Dependencies + + private let service: DirectMessagesServicing + private let bus: DirectMessagesEventBus? + private let currentUserIDProvider: @MainActor () -> String? + private let pollInterval: Duration + + // MARK: - Observable state + + /// The rendered thread, oldest-first (chat order — newest at the + /// bottom). The server returns a `DMThread`; we normalise order once + /// on load and append in place thereafter. + private(set) var messages: [DirectMessage] = [] + + /// The resolved other user, populated from the first `thread(...)`. + private(set) var otherUser: UserSummary? + + /// Whether the two users mutually follow (server-reported). When + /// `false`, the composer is disabled — a non-mutual can't be messaged. + private(set) var isMutual: Bool = false + + /// Whether the current user has blocked (or is blocked by) the other. + /// When `true`, the composer is disabled. + private(set) var isBlocked: Bool = false + + /// Cursor for loading older messages, or `nil` when the head is + /// reached. + private(set) var olderCursor: String? + + /// The composer draft. Two-way bound by the view. + var draft: String = "" + + /// True while the initial thread load is in flight. + private(set) var isLoading: Bool = false + + /// True while a send round-trip is in flight. + private(set) var isSending: Bool = false + + /// Surfaced error from the most recent failed load / send. Polling + /// failures are swallowed (a dropped poll should not error the UI). + private(set) var error: Error? + + /// True once the initial load resolved (success or failure). + private(set) var hasLoadedOnce: Bool = false + + /// Whether the composer can currently send: mutual, not blocked, and + /// a non-blank draft. + var canSend: Bool { + isMutual && !isBlocked && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: - Internals + + /// Owns the polling loop so `stopPolling()` cancels it. `[weak self]` + /// capture; no `deinit`-time cancel (Observation-macro semantics). + private var pollTask: Task? + + /// The recipient user id, learned from the first inbound/outbound + /// message or the resolved other user. Needed to `send`. + private var recipientId: String? + + /// Monotonic temp-id source for optimistic placeholders. + private var optimisticSeq: Int = 0 + + // MARK: - Init + + init( + username: String, + service: DirectMessagesServicing, + eventBus: DirectMessagesEventBus? = nil, + currentUserID: @MainActor @escaping () -> String? = { nil }, + pollInterval: Duration = defaultPollInterval + ) { + self.username = username + self.service = service + self.bus = eventBus + self.currentUserIDProvider = currentUserID + self.pollInterval = pollInterval + } + + // MARK: - Lifecycle + + /// Loads the thread, marks inbound messages read, and starts the + /// live poll. Bound to the view's `.task`. Idempotent — re-entry + /// replaces the prior poll task. + func startPolling() async { + await load() + await markInboundRead() + pollTask?.cancel() + pollTask = Task { [weak self] in + guard let self else { return } + await self.pollLoop() + } + } + + /// Cancels the poll loop. Bound to `.onDisappear`. Idempotent. + func stopPolling() { + pollTask?.cancel() + pollTask = nil + } + + // MARK: - Load + + /// Initial thread load. Normalises to oldest-first and captures the + /// other user / mutual / blocked flags and the recipient id. + func load() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let thread = try await service.thread(username: username, cursor: nil) + apply(thread, replacing: true) + error = nil + hasLoadedOnce = true + } catch { + self.error = error + hasLoadedOnce = true + } + } + + // MARK: - Send + + /// Sends the current draft. Blank drafts (no text, no images) are + /// rejected before the service is touched — `canSend` gates the UI but + /// this guard makes the rejection authoritative. Optimistic: append a + /// placeholder, call `send`, replace it with the server message on + /// success, or remove it and restore the draft on failure. + func send() async { + let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard let recipientId = resolvedRecipientId() else { + // No recipient id could be resolved (empty thread with no + // resolved other user). Surface a typed error rather than + // firing a doomed request. + error = DMThreadError.unknownRecipient + return + } + guard !isSending else { return } + isSending = true + defer { isSending = false } + + // Optimistic placeholder. + optimisticSeq += 1 + let tempId = "optimistic-\(optimisticSeq)" + let me = currentUserIDProvider() + let placeholder = DirectMessage( + id: tempId, + senderId: me ?? "", + recipientId: recipientId, + body: trimmed, + imageURLs: [], + createdAt: Date(), + readAt: nil, + sender: nil, + recipient: otherUser + ) + messages.append(placeholder) + let priorDraft = draft + draft = "" + + do { + let sent = try await service.send(recipientId: recipientId, body: trimmed) + // Replace the placeholder with the authoritative server value. + if let index = messages.firstIndex(where: { $0.id == tempId }) { + messages[index] = sent + } else { + messages.append(sent) + } + error = nil + bus?.post(.messageSent(recipientUsername: username, message: sent)) + } catch { + messages.removeAll { $0.id == tempId } + draft = priorDraft + self.error = error + } + } + + // MARK: - Mark read + + /// Marks every unread inbound message read, then posts `threadRead` + /// and re-publishes the server unread count so peer surfaces update. + /// Soft-fails per message: one failed mark-read does not error the UI. + func markInboundRead() async { + let me = currentUserIDProvider() + let unread = messages.filter { isUnreadInbound($0, me: me) } + guard !unread.isEmpty else { return } + var didMarkAny = false + for message in unread { + do { + try await service.markRead(id: message.id) + if let index = messages.firstIndex(where: { $0.id == message.id }) { + messages[index] = withReadAt(messages[index], date: Date()) + } + didMarkAny = true + } catch { + // Soft — leave the message unread; a later poll / reopen + // retries. + } + } + if didMarkAny { + bus?.post(.threadRead(username: username)) + await republishUnreadCount() + } + } + + // MARK: - Poll + + private func pollLoop() async { + while !Task.isCancelled { + do { + try await Task.sleep(for: pollInterval) + } catch { + // Cancelled during sleep. + return + } + guard !Task.isCancelled else { return } + await pollOnce() + } + } + + /// One `threadUpdates` fetch, merged in place. Visible for tests so a + /// poll cycle can be driven deterministically without waiting out the + /// interval. Marks any newly-arrived inbound messages read. + func pollOnce() async { + do { + let update = try await service.threadUpdates(username: username, since: newestId) + guard !update.messages.isEmpty || update.otherUser != nil else { return } + mergeUpdates(update) + await markInboundRead() + } catch { + // Swallow — a dropped poll must not surface an error to the UI. + } + } + + // MARK: - Merge + + private func apply(_ thread: DMThread, replacing: Bool) { + let ordered = thread.messages.sorted { $0.createdAt < $1.createdAt } + if replacing { + messages = ordered + } else { + mergeMessages(ordered) + } + if let other = thread.otherUser { otherUser = other } + isMutual = thread.isMutual + isBlocked = thread.isBlocked + olderCursor = thread.olderCursor + cacheRecipientId() + } + + private func mergeUpdates(_ thread: DMThread) { + mergeMessages(thread.messages) + if let other = thread.otherUser { otherUser = other } + // `threadUpdates` reflects live mutual / block changes too. + isMutual = thread.isMutual + isBlocked = thread.isBlocked + cacheRecipientId() + } + + /// Merges `incoming` into `messages` by id, preserving oldest-first + /// order. New ids are appended (and the list re-sorted by time); + /// existing ids are updated in place (e.g. a read-receipt landing). + private func mergeMessages(_ incoming: [DirectMessage]) { + guard !incoming.isEmpty else { return } + var byId = Dictionary(uniqueKeysWithValues: messages.map { ($0.id, $0) }) + var appended = false + for message in incoming { + if byId[message.id] != nil { + byId[message.id] = message + if let index = messages.firstIndex(where: { $0.id == message.id }) { + messages[index] = message + } + } else { + byId[message.id] = message + messages.append(message) + appended = true + } + } + if appended { + messages.sort { $0.createdAt < $1.createdAt } + } + } + + // MARK: - Helpers + + /// Newest message id, used as the `since` token for `threadUpdates`. + private var newestId: String? { messages.last?.id } + + private func republishUnreadCount() async { + do { + let count = try await service.unreadCount() + bus?.post(.unreadCountChanged(count)) + } catch { + // Soft. + } + } + + private func resolvedRecipientId() -> String? { + cacheRecipientId() + return recipientId + } + + /// Derives the recipient id from the resolved other user or from the + /// non-me side of any message. Cached once known. + private func cacheRecipientId() { + if let recipientId, !recipientId.isEmpty { return } + if let id = otherUser?.id, !id.isEmpty { + recipientId = id + return + } + let me = currentUserIDProvider() + for message in messages { + if let me { + let otherId = message.senderId == me ? message.recipientId : message.senderId + if !otherId.isEmpty { recipientId = otherId; return } + } else if !message.senderId.isEmpty { + recipientId = message.senderId + return + } + } + } + + private func isUnreadInbound(_ message: DirectMessage, me: String?) -> Bool { + guard !message.isRead else { return false } + guard let me else { return message.senderId != (otherUser?.id ?? "") ? false : true } + return message.recipientId == me + } + + private func withReadAt(_ message: DirectMessage, date: Date) -> DirectMessage { + DirectMessage( + id: message.id, + senderId: message.senderId, + recipientId: message.recipientId, + body: message.body, + imageURLs: message.imageURLs, + createdAt: message.createdAt, + readAt: message.readAt ?? date, + sender: message.sender, + recipient: message.recipient + ) + } + + // MARK: - Test seam + + /// Seeds the rendered thread without a service call. For tests / previews. + func seedForTest( + messages: [DirectMessage], + otherUser: UserSummary? = nil, + isMutual: Bool = true, + isBlocked: Bool = false + ) { + self.messages = messages.sorted { $0.createdAt < $1.createdAt } + self.otherUser = otherUser + self.isMutual = isMutual + self.isBlocked = isBlocked + self.hasLoadedOnce = true + cacheRecipientId() + } +} + +// MARK: - DMThreadError + +/// Local validation errors raised before any service call. +enum DMThreadError: Error, Equatable { + /// `send` was invoked but no recipient id could be resolved (an empty + /// thread with no resolved other user). + case unknownRecipient +} diff --git a/App/Features/DirectMessages/DirectMessagesListViewModel.swift b/App/Features/DirectMessages/DirectMessagesListViewModel.swift new file mode 100644 index 0000000..5041abd --- /dev/null +++ b/App/Features/DirectMessages/DirectMessagesListViewModel.swift @@ -0,0 +1,289 @@ +// DirectMessagesListViewModel +// +// Drives the conversation-list column of `DirectMessagesRootView` (the- +// gaps.md G1). Owns the selected folder (Inbox / Sent / Deleted), the +// folder listing collapsed into per-conversation rows, pagination via +// the `DMPage.nextCursor`, the unread badge count, and the trash / +// restore actions. Reads through `DirectMessagesServicing` only — no +// direct API access — so unit tests substitute a stub service. +// +// Conversation grouping: the folder listing is a flat, newest-first list +// of `DirectMessage`s. A conversation is "the other participant" — the +// non-current user on each message. We fold the flat list into one +// `DMConversation` per other-user, keeping the newest message as the +// preview and counting unread inbound messages. Grouping needs the +// current user id (to know which side is "other"); it comes from the +// injected `currentUserID` closure so the view model always sees the +// latest session (mirrors `ProfileViewModel`). +// +// Optimistic trash / restore (per the swift-engineer skill): snapshot the +// affected messages, mutate locally, call the service, and on failure +// restore the snapshot and surface the error. A `pendingOperations` set +// keyed by message id debounces rapid re-taps. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +// MARK: - DMConversation + +/// One collapsed conversation row: the other participant, the newest +/// message as a preview, and the count of unread inbound messages. +struct DMConversation: Identifiable, Equatable, Sendable { + /// Stable identity — the other participant's user id when known, else + /// their username, else the newest message id. Never empty. + let id: String + /// The other participant (nil-safe display handled by the view). + let otherUser: UserSummary? + /// A stable username used to open the thread. Falls back through the + /// other user's username; empty only in the degenerate no-user case. + let otherUsername: String + /// The newest message in the conversation, rendered as the preview. + let latestMessage: DirectMessage + /// Count of inbound (received, unread) messages in this conversation. + let unreadCount: Int + /// All messages in the conversation from this folder page, newest-first. + let messages: [DirectMessage] +} + +@MainActor +@Observable +final class DirectMessagesListViewModel { + + // MARK: - Dependencies + + private let service: DirectMessagesServicing + private let bus: DirectMessagesEventBus? + private let currentUserIDProvider: @MainActor () -> String? + + // MARK: - Observable state + + /// The folder whose listing is shown. Changing it triggers a reload. + var folder: DMFolder = .inbox { + didSet { + guard folder != oldValue else { return } + Task { await load() } + } + } + + /// The collapsed conversation rows for the current folder, newest-first. + private(set) var conversations: [DMConversation] = [] + + /// Server-authoritative unread count from `unreadCount()`. Drives the + /// sidebar pip / dock badge (via the bus). + private(set) var unreadCount: Int = 0 + + /// Cursor for the next page, or `nil` when the listing is exhausted. + private(set) var nextCursor: String? + + /// True when another page can be loaded. + var hasMore: Bool { nextCursor != nil } + + /// True while a full (re)load is in flight. + private(set) var isLoading: Bool = false + + /// True while a next-page fetch is in flight. + private(set) var isLoadingMore: Bool = false + + /// Surfaced error from the most recent failed load / action. + private(set) var error: Error? + + /// True once the first load resolved (success or failure). Lets the + /// view distinguish first-render shimmer from a genuinely empty folder. + private(set) var hasLoadedOnce: Bool = false + + /// Per-message debounce set so rapid trash/restore re-taps on the + /// same message don't double-fire the service. + private var pendingOperations: Set = [] + + /// The flat, newest-first message list backing `conversations`. Kept + /// so trash/restore can mutate the source and re-group. + private var messages: [DirectMessage] = [] + + // MARK: - Init + + init( + service: DirectMessagesServicing, + eventBus: DirectMessagesEventBus? = nil, + currentUserID: @MainActor @escaping () -> String? = { nil } + ) { + self.service = service + self.bus = eventBus + self.currentUserIDProvider = currentUserID + } + + // MARK: - Intents + + /// First-time load + pull-to-refresh for the current folder. Replaces + /// the rendered list and refreshes the unread count. + func load() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await service.folder(folder, cursor: nil) + messages = page.messages + nextCursor = page.nextCursor + regroup() + error = nil + hasLoadedOnce = true + } catch { + self.error = error + hasLoadedOnce = true + } + // Refresh the unread badge alongside the listing. A failed unread + // read is soft — the listing is the load-bearing data. + await refreshUnreadCount() + } + + /// Appends the next page when the user scrolls to the end. No-op when + /// there is no next cursor or a page fetch is already in flight. + func loadMore() async { + guard let cursor = nextCursor, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let page = try await service.folder(folder, cursor: cursor) + messages.append(contentsOf: page.messages) + nextCursor = page.nextCursor + regroup() + error = nil + } catch { + self.error = error + } + } + + /// Re-reads the server unread count and publishes it on the bus so the + /// dock badge / sidebar pip update. Soft-fails: a failed read leaves + /// the prior count in place and does not surface an error. + func refreshUnreadCount() async { + do { + let count = try await service.unreadCount() + unreadCount = count + bus?.post(.unreadCountChanged(count)) + } catch { + // Soft — keep the prior count. + } + } + + /// Moves a message to the Deleted folder (from Inbox / Sent). + /// Optimistic: drop it from the local listing, call `trash`, and on + /// failure restore the snapshot and surface the error. + func trash(messageID: String) async { + guard !pendingOperations.contains(messageID) else { return } + guard messages.contains(where: { $0.id == messageID }) else { return } + pendingOperations.insert(messageID) + defer { pendingOperations.remove(messageID) } + + let snapshot = messages + messages.removeAll { $0.id == messageID } + regroup() + do { + try await service.trash(id: messageID) + error = nil + await refreshUnreadCount() + } catch { + messages = snapshot + regroup() + self.error = error + } + } + + /// Restores a message out of the Deleted folder. Optimistic in the + /// same shape as `trash`. + func restore(messageID: String) async { + guard !pendingOperations.contains(messageID) else { return } + guard messages.contains(where: { $0.id == messageID }) else { return } + pendingOperations.insert(messageID) + defer { pendingOperations.remove(messageID) } + + let snapshot = messages + messages.removeAll { $0.id == messageID } + regroup() + do { + try await service.restore(id: messageID) + error = nil + await refreshUnreadCount() + } catch { + messages = snapshot + regroup() + self.error = error + } + } + + /// Seeds the flat listing without going through the service. For tests + /// and previews. + func seedForTest(messages: [DirectMessage], nextCursor: String? = nil, unreadCount: Int = 0) { + self.messages = messages + self.nextCursor = nextCursor + self.unreadCount = unreadCount + self.hasLoadedOnce = true + regroup() + } + + // MARK: - Grouping + + /// Folds the flat message list into one conversation per other-user, + /// newest-first. Stable: ties keep the newest message's timestamp. + private func regroup() { + let me = currentUserIDProvider() + var order: [String] = [] + var buckets: [String: [DirectMessage]] = [:] + + for message in messages { + let key = conversationKey(for: message, me: me) + if buckets[key] == nil { + buckets[key] = [] + order.append(key) + } + buckets[key]?.append(message) + } + + conversations = order.compactMap { key -> DMConversation? in + guard let bucket = buckets[key], let latest = bucket.first else { return nil } + let other = otherUser(in: latest, me: me) + let username = other?.username ?? key + let unread = bucket.filter { isUnreadInbound($0, me: me) }.count + return DMConversation( + id: key, + otherUser: other, + otherUsername: username, + latestMessage: latest, + unreadCount: unread, + messages: bucket + ) + } + } + + /// The other participant on a message, given the current user id. + /// Without a resolved current user we cannot tell which side is + /// "other", so we fall back to the recipient (the common inbox case). + private func otherUser(in message: DirectMessage, me: String?) -> UserSummary? { + guard let me else { return message.sender ?? message.recipient } + return message.senderId == me ? message.recipient : message.sender + } + + /// A stable grouping key: the other participant's id when derivable, + /// else their username, else the message id (degenerate). + private func conversationKey(for message: DirectMessage, me: String?) -> String { + if let me { + let otherId = message.senderId == me ? message.recipientId : message.senderId + if !otherId.isEmpty { return otherId } + } + if let username = otherUser(in: message, me: me)?.username, !username.isEmpty { + return username + } + return message.id + } + + /// A message counts toward unread when it was received (not sent by + /// me) and has not been read. Without a resolved current user we + /// treat unread-and-not-outgoing conservatively as inbound. + private func isUnreadInbound(_ message: DirectMessage, me: String?) -> Bool { + guard !message.isRead else { return false } + guard let me else { return true } + return message.recipientId == me + } +} diff --git a/App/Features/DirectMessages/DirectMessagesRootView.swift b/App/Features/DirectMessages/DirectMessagesRootView.swift new file mode 100644 index 0000000..926d8a1 --- /dev/null +++ b/App/Features/DirectMessages/DirectMessagesRootView.swift @@ -0,0 +1,275 @@ +// DirectMessagesRootView +// +// The Direct Messages sidebar section (the-gaps.md G1). A three-part flow +// inside the detail column: +// 1. a folder switcher (Inbox / Sent / Deleted) — a segmented picker, +// 2. the conversation list for that folder (grouped by other participant), +// 3. the thread for the selected conversation. +// +// The folder + list are driven by `DirectMessagesListViewModel`; the +// selected thread is rendered by `DMThreadView`, re-identified by +// username so switching conversations tears down the old poll and starts +// a fresh one. A "New message" toolbar button presents `NewMessageSheet`. +// +// The current user id — needed for conversation grouping and bubble +// alignment — is read from `CurrentUserStore` (the App-layer session +// projection), threaded down as a closure so the view models always see +// the latest session (mirrors `ProfileRootView`). +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct DirectMessagesRootView: View { + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: DirectMessagesListViewModel? + @State private var selectedUsername: String? + @State private var showNewMessage = false + + var body: some View { + NavigationStack { + Group { + if let viewModel { + listBody(viewModel: viewModel) + } else { + unconfiguredState + } + } + .navigationTitle("Messages") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showNewMessage = true + } label: { + Label("New message", systemImage: "square.and.pencil") + } + .accessibilityLabel("New message") + } + } + } + .task { + if viewModel == nil, let environment { + let vm = DirectMessagesListViewModel( + service: environment.directMessages, + eventBus: environment.directMessagesEventBus, + currentUserID: { [weak environment] in + environment?.currentUserStore.currentUserID + } + ) + viewModel = vm + await vm.load() + } + } + .sheet(isPresented: $showNewMessage) { + NewMessageSheet(onSent: { username in + selectedUsername = username + Task { await viewModel?.load() } + }) + } + // Open a thread when the "Message" button on a profile fires. + .onReceive(NotificationCenter.default.publisher(for: .directMessagesOpenThread)) { note in + guard let username = note.object as? String else { return } + selectedUsername = username + } + } + + // MARK: - Body + + @ViewBuilder + private func listBody(viewModel: DirectMessagesListViewModel) -> some View { + HSplitView { + VStack(spacing: 0) { + folderPicker(viewModel: viewModel) + Divider() + conversationList(viewModel: viewModel) + } + .frame(minWidth: 260, idealWidth: 300) + + threadPane + .frame(minWidth: 320) + } + } + + @ViewBuilder + private func folderPicker(viewModel: DirectMessagesListViewModel) -> some View { + Picker( + "Folder", + selection: Binding( + get: { viewModel.folder }, + set: { viewModel.folder = $0 } + ) + ) { + ForEach(DMFolder.allCases) { folder in + Text(folder.label).tag(folder) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(12) + } + + @ViewBuilder + private func conversationList(viewModel: DirectMessagesListViewModel) -> some View { + if let error = viewModel.error, viewModel.conversations.isEmpty { + errorState(error: error, viewModel: viewModel) + } else if viewModel.conversations.isEmpty, viewModel.hasLoadedOnce { + emptyState(folder: viewModel.folder) + } else { + List(selection: $selectedUsername) { + ForEach(viewModel.conversations) { conversation in + conversationRow(conversation, viewModel: viewModel) + .tag(conversation.otherUsername) + } + if viewModel.hasMore { + HStack { + Spacer() + ProgressView().controlSize(.small) + Spacer() + } + .task { await viewModel.loadMore() } + } + } + .listStyle(.inset) + .refreshable { await viewModel.load() } + .overlay { + if viewModel.isLoading, !viewModel.hasLoadedOnce { + ProgressView("Loading…") + } + } + } + } + + @ViewBuilder + private func conversationRow( + _ conversation: DMConversation, + viewModel: DirectMessagesListViewModel + ) -> some View { + HStack(spacing: 10) { + DMAvatarView(user: conversation.otherUser, size: 36) + VStack(alignment: .leading, spacing: 2) { + Text(conversation.otherUser?.displayName ?? "@\(conversation.otherUsername)") + .font(.body.weight(conversation.unreadCount > 0 ? .semibold : .regular)) + .lineLimit(1) + Text(conversation.latestMessage.body) + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if conversation.unreadCount > 0 { + Text("\(conversation.unreadCount)") + .font(.ilMono(10)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.accentColor)) + .foregroundStyle(.white) + .accessibilityLabel("\(conversation.unreadCount) unread") + } + } + .padding(.vertical, 4) + .contextMenu { + if viewModel.folder == .deleted { + Button { + Task { await viewModel.restore(messageID: conversation.latestMessage.id) } + } label: { + Label("Restore", systemImage: "arrow.uturn.backward") + } + } else { + Button(role: .destructive) { + Task { await viewModel.trash(messageID: conversation.latestMessage.id) } + } label: { + Label("Move to Deleted", systemImage: "trash") + } + } + } + } + + @ViewBuilder + private var threadPane: some View { + if let username = selectedUsername { + DMThreadView( + username: username, + currentUserID: { [weak environment] in + environment?.currentUserStore.currentUserID + } + ) + .id(username) + } else { + VStack(spacing: 8) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.ilDisplay(36)) + .foregroundStyle(Color.accentColor) + Text("Select a conversation") + .font(.ilSubtitle()) + Text("Pick a conversation on the left, or start a new message.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - States + + private func emptyState(folder: DMFolder) -> some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.ilDisplay(32)) + .foregroundStyle(.secondary) + Text("No messages in \(folder.label)") + .font(.ilSubtitle()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func errorState(error: Error, viewModel: DirectMessagesListViewModel) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.ilDisplay(32)) + .foregroundStyle(Color.accentColor) + Text("Couldn't load messages") + .font(.ilSubtitle()) + Text(error.localizedDescription) + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Button("Try again") { + Task { await viewModel.load() } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var unconfiguredState: some View { + VStack(spacing: 8) { + Image(systemName: "wrench.adjustable") + .font(.ilDisplay(36)) + .foregroundStyle(.secondary) + Text("Messages unavailable") + .font(.ilSubtitle()) + Text("AppEnvironment is not injected into the view tree.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Cross-scene routing + +extension Foundation.Notification.Name { + /// Posted with a username `object` when the user taps "Message" on a + /// profile. `MainWindowView` switches the sidebar to Messages and + /// `DirectMessagesRootView` selects that conversation's thread. + static let directMessagesOpenThread = Foundation.Notification.Name("InterlinedList.directMessagesOpenThread") + + /// Posted by the ⌥⌘M menu command / the Messages menu to route the + /// sidebar to the Messages section. + static let directMessagesShow = Foundation.Notification.Name("InterlinedList.directMessagesShow") +} diff --git a/App/Features/DirectMessages/NewMessageSheet.swift b/App/Features/DirectMessages/NewMessageSheet.swift new file mode 100644 index 0000000..02d0984 --- /dev/null +++ b/App/Features/DirectMessages/NewMessageSheet.swift @@ -0,0 +1,134 @@ +// NewMessageSheet +// +// Modal composer for a brand-new conversation (the-gaps.md G1). A +// recipient picker over the eligible-recipient set (`recipients()`, mutual +// followers) plus a body field. A thin shell over `NewMessageViewModel`. +// +// On a successful send the sheet dismisses and reports the recipient's +// username via `onSent` so the root view can select that conversation and +// open its thread. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct NewMessageSheet: View { + + /// Optional username to preselect (the "Message" button on a profile). + var preselectUsername: String? = nil + /// Called with the recipient username after a successful send. + var onSent: (String) -> Void = { _ in } + + @Environment(\.appEnvironment) private var environment + @Environment(\.dismiss) private var dismiss + + @State private var viewModel: NewMessageViewModel? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New message") + .font(.ilTitle()) + + if let viewModel { + content(viewModel: viewModel) + } else { + ProgressView().frame(maxWidth: .infinity) + } + } + .padding(20) + .frame(width: 460) + .task { + if viewModel == nil, let environment { + let vm = NewMessageViewModel( + service: environment.directMessages, + eventBus: environment.directMessagesEventBus + ) + viewModel = vm + await vm.loadRecipients(preselectUsername: preselectUsername) + } + } + } + + @ViewBuilder + private func content(viewModel: NewMessageViewModel) -> some View { + VStack(alignment: .leading, spacing: 12) { + if viewModel.isLoadingRecipients { + ProgressView("Loading recipients…") + } else if viewModel.recipients.isEmpty, viewModel.hasLoadedRecipients { + Label( + "You have no mutual followers to message yet.", + systemImage: "person.2.slash" + ) + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + } else { + Picker( + "To", + selection: Binding( + get: { viewModel.selectedRecipientId }, + set: { viewModel.selectedRecipientId = $0 } + ) + ) { + Text("Select a recipient").tag(String?.none) + ForEach(viewModel.recipients) { user in + Text("\(user.displayName) (@\(user.username))") + .tag(String?.some(user.id)) + } + } + .pickerStyle(.menu) + .accessibilityLabel("Recipient") + } + + VStack(alignment: .leading, spacing: 4) { + Text("Message") + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + TextEditor( + text: Binding( + get: { viewModel.body }, + set: { viewModel.body = $0 } + ) + ) + .frame(minHeight: 96) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .accessibilityLabel("Message body") + } + + if let error = viewModel.error { + Text(error.localizedDescription) + .font(.ilSubtitle()) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button { + Task { + await viewModel.send() + if let sent = viewModel.sentMessage, + let username = viewModel.selectedRecipientUsername { + _ = sent + onSent(username) + dismiss() + } + } + } label: { + if viewModel.isSending { + ProgressView().controlSize(.small) + } else { + Text("Send") + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canSend || viewModel.isSending) + } + } + } +} diff --git a/App/Features/DirectMessages/NewMessageViewModel.swift b/App/Features/DirectMessages/NewMessageViewModel.swift new file mode 100644 index 0000000..32c330a --- /dev/null +++ b/App/Features/DirectMessages/NewMessageViewModel.swift @@ -0,0 +1,143 @@ +// NewMessageViewModel +// +// Backs the "New message" composer sheet (the-gaps.md G1). Loads the set +// of eligible recipients (mutual followers) via `recipients()`, tracks the +// selection + body draft, and sends. Reads through +// `DirectMessagesServicing` only so unit tests substitute a stub service. +// +// Send validation: a blank body (whitespace-only, no images) is rejected +// before the service is touched — the "invalid input rejected before the +// service is called" gate. A send with no selected recipient is likewise +// rejected locally. On success the sheet reports the recipient's username +// so the caller can open the thread; the bus is notified so open list / +// thread surfaces update in place. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class NewMessageViewModel { + + // MARK: - Dependencies + + private let service: DirectMessagesServicing + private let bus: DirectMessagesEventBus? + + // MARK: - Observable state + + /// The eligible recipients (mutual followers) from `recipients()`. + private(set) var recipients: [UserSummary] = [] + + /// The selected recipient's id, or `nil` before a pick. The picker + /// binds to this. + var selectedRecipientId: String? + + /// The message body draft. Two-way bound by the sheet. + var body: String = "" + + /// True while the recipient list is loading. + private(set) var isLoadingRecipients: Bool = false + + /// True while the send round-trip is in flight. + private(set) var isSending: Bool = false + + /// Surfaced error from the most recent failed load / send. + private(set) var error: Error? + + /// Set to the message the send produced, so the sheet can dismiss and + /// the caller can open the thread. `nil` until a successful send. + private(set) var sentMessage: DirectMessage? + + /// True once the recipient load resolved (success or failure). + private(set) var hasLoadedRecipients: Bool = false + + /// Whether send is currently possible: a recipient is picked and the + /// body is non-blank. + var canSend: Bool { + selectedRecipientId != nil + && !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: - Init + + init( + service: DirectMessagesServicing, + eventBus: DirectMessagesEventBus? = nil + ) { + self.service = service + self.bus = eventBus + } + + // MARK: - Intents + + /// Loads the eligible recipient list. If a `preselectUsername` is + /// supplied (the "Message" button on a profile), the matching + /// recipient is auto-selected once the list resolves. + func loadRecipients(preselectUsername: String? = nil) async { + guard !isLoadingRecipients else { return } + isLoadingRecipients = true + defer { isLoadingRecipients = false } + do { + let list = try await service.recipients() + recipients = list + error = nil + hasLoadedRecipients = true + if let preselectUsername, + let match = list.first(where: { $0.username == preselectUsername }) { + selectedRecipientId = match.id + } + } catch { + self.error = error + hasLoadedRecipients = true + } + } + + /// Sends the drafted message to the selected recipient. Rejects a + /// missing recipient or a blank body before the service is touched. + /// On success sets `sentMessage` and posts to the bus. + func send() async { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard let recipientId = selectedRecipientId, !recipientId.isEmpty else { + error = NewMessageError.noRecipient + return + } + guard !trimmed.isEmpty else { + error = NewMessageError.emptyBody + return + } + guard !isSending else { return } + isSending = true + defer { isSending = false } + do { + let sent = try await service.send(recipientId: recipientId, body: trimmed) + sentMessage = sent + error = nil + if let username = recipients.first(where: { $0.id == recipientId })?.username { + bus?.post(.messageSent(recipientUsername: username, message: sent)) + } + } catch { + self.error = error + } + } + + /// The username of the currently-selected recipient, for the caller to + /// open the thread after a successful send. + var selectedRecipientUsername: String? { + guard let id = selectedRecipientId else { return nil } + return recipients.first(where: { $0.id == id })?.username + } +} + +// MARK: - NewMessageError + +/// Local validation errors raised before any service call. +enum NewMessageError: Error, Equatable { + /// Send was invoked with no recipient selected. + case noRecipient + /// Send was invoked with a blank body. + case emptyBody +} diff --git a/App/Features/DirectMessages/ProfileMessageButton.swift b/App/Features/DirectMessages/ProfileMessageButton.swift new file mode 100644 index 0000000..511ae42 --- /dev/null +++ b/App/Features/DirectMessages/ProfileMessageButton.swift @@ -0,0 +1,55 @@ +// ProfileMessageButton +// +// The additive "Message" affordance embedded in `ProfileHeaderView` (the- +// gaps.md G1). Self-contained and self-gating: it owns a +// `ProfileMessageButtonViewModel`, checks recipient eligibility on +// appear, and renders *nothing* unless the profiled user is an eligible +// recipient (a mutual follower who hasn't blocked the current user). This +// keeps the header pure presentation — it simply drops the button in and +// lets the button decide whether to show. +// +// Tapping the button posts `.directMessagesShow` (route the sidebar to +// Messages) and `.directMessagesOpenThread` (select this conversation), +// reusing the same cross-scene notification channel the other features +// use so the header needs no navigation wiring. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct ProfileMessageButton: View { + + let username: String + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: ProfileMessageButtonViewModel? + + var body: some View { + Group { + if let viewModel, viewModel.shouldShow { + Button { + NotificationCenter.default.post(name: .directMessagesShow, object: nil) + NotificationCenter.default.post(name: .directMessagesOpenThread, object: username) + } label: { + Label("Message", systemImage: "bubble.left") + } + .buttonStyle(.bordered) + .accessibilityLabel("Message @\(username)") + } + } + .task(id: username) { + guard let environment else { return } + let vm = ProfileMessageButtonViewModel( + username: username, + service: environment.directMessages, + currentUsername: { [weak environment] in + environment?.currentUserStore.currentUsername + } + ) + viewModel = vm + await vm.check() + } + } +} diff --git a/App/Features/DirectMessages/ProfileMessageButtonViewModel.swift b/App/Features/DirectMessages/ProfileMessageButtonViewModel.swift new file mode 100644 index 0000000..2641ae3 --- /dev/null +++ b/App/Features/DirectMessages/ProfileMessageButtonViewModel.swift @@ -0,0 +1,95 @@ +// ProfileMessageButtonViewModel +// +// Backs the additive "Message" affordance on `ProfileHeaderView` (the- +// gaps.md G1). The button opens a DM thread with the profiled user, but +// only when that user is an *eligible recipient* — a mutual follower who +// has not blocked the current user. Eligibility is server-defined: the +// authoritative source is `recipients()` (the mutual-follower set), so we +// check membership by username rather than re-deriving mutual state on the +// client. +// +// Ownership-gating (per the swift-engineer skill): while the current user +// is unknown (session not resolved) OR the profiled user is the current +// user OR the eligibility check has not yet resolved, the button hides +// itself. `isEligible == nil` is the "hidden / undetermined" signal; only +// `true` renders the button. This never renders an enabled-but-broken +// action. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ProfileMessageButtonViewModel { + + // MARK: - Subject + + /// The profiled user's username. + let username: String + + // MARK: - Dependencies + + private let service: DirectMessagesServicing + private let currentUsernameProvider: @MainActor () -> String? + + // MARK: - Observable state + + /// Tri-state eligibility: + /// - `nil` — undetermined (not yet checked) OR gated (self / + /// unknown session); the button hides. + /// - `false` — checked, not a mutual recipient; the button hides. + /// - `true` — checked, eligible; the button shows. + private(set) var isEligible: Bool? + + /// True while the eligibility check is in flight. + private(set) var isChecking: Bool = false + + /// Whether the button should render. Only a resolved `true` shows it. + var shouldShow: Bool { isEligible == true } + + // MARK: - Init + + init( + username: String, + service: DirectMessagesServicing, + currentUsername: @MainActor @escaping () -> String? = { nil } + ) { + self.username = username + self.service = service + self.currentUsernameProvider = currentUsername + } + + // MARK: - Intents + + /// Resolves eligibility. Short-circuits to hidden (`nil`) when the + /// session is unresolved or the subject is the current user — neither + /// case should touch the network. Otherwise checks membership of the + /// `recipients()` set. A failed check leaves the button hidden + /// (`isEligible == false`) rather than surfacing an error — a + /// best-effort affordance should fail closed. + func check() async { + guard let me = currentUsernameProvider() else { + // Unresolved session — hide, don't check. + isEligible = nil + return + } + guard me != username else { + // Self-profile — no "Message yourself". + isEligible = false + return + } + guard !isChecking else { return } + isChecking = true + defer { isChecking = false } + do { + let recipients = try await service.recipients() + isEligible = recipients.contains { $0.username == username } + } catch { + // Fail closed — a broken eligibility check hides the button. + isEligible = false + } + } +} diff --git a/App/Features/Documents/DocumentTemplatePickerView.swift b/App/Features/Documents/DocumentTemplatePickerView.swift new file mode 100644 index 0000000..fbdf04c --- /dev/null +++ b/App/Features/Documents/DocumentTemplatePickerView.swift @@ -0,0 +1,224 @@ +// DocumentTemplatePickerView +// +// Sheet for "New Document from Template…" (feature-gaps.md §1.4, the-gaps.md +// G12). Presents two sections: +// +// • "Built-in" — the bundled `DocumentTemplate.builtIn` catalog (Blank / +// Meeting Notes / Daily Log / PRD). Selecting one seeds a fresh document +// from its starter Markdown through `DocumentsListViewModel.createDocument`. +// This is a purely client-side path; unchanged from the original picker. +// +// • "Your templates" — the user's own **server-side** saved templates +// (`DocumentTemplateRef`), fetched by `ServerTemplatesViewModel`. Selecting +// one calls `createFromTemplate` and then reloads the documents list so the +// new document appears, opening it in the editor. +// +// The server section is loaded lazily and its failures are non-fatal: if the +// fetch throws, the built-in section still renders and the error is surfaced in +// a subtle inline row. An empty result shows a "No saved templates yet" row with +// a "Seed defaults" affordance. +// +// Pure SwiftUI; no AppKit involvement. Per Decision 0003 the view imports only +// InterlinedDomain. + +import SwiftUI +import InterlinedDomain + +struct DocumentTemplatePickerView: View { + + /// The list view model that owns the create path. Bindable so the sheet + /// reacts to its `error` and any in-flight state. + @Bindable var viewModel: DocumentsListViewModel + + /// Drives the "Your templates" section — the server-side catalog. Bindable + /// so the sheet reacts to its load / create state. + @Bindable var serverTemplates: ServerTemplatesViewModel + + /// Called with the created document on success so the caller (the root + /// view) can bind the editor to it. Not called on failure. + let onCreated: (Document) -> Void + + /// The built-in catalog to present. Defaults to the bundled built-ins; + /// injectable so previews can substitute a list. + var builtInTemplates: [DocumentTemplate] = DocumentTemplate.builtIn + + @Environment(\.dismiss) private var dismiss + @State private var selection: TemplateSelection? + @State private var isCreating = false + + /// A unified selection across both sections so a single `List` selection + /// binding drives the primary action regardless of which kind is picked. + private enum TemplateSelection: Hashable { + case builtIn(DocumentTemplate.ID) + case server(DocumentTemplateRef.ID) + } + + private var selectedBuiltIn: DocumentTemplate? { + guard case let .builtIn(id) = selection else { return nil } + return builtInTemplates.first { $0.id == id } + } + + private var selectedServer: DocumentTemplateRef? { + guard case let .server(id) = selection else { return nil } + return serverTemplates.templates.first { $0.id == id } + } + + private var canCreate: Bool { + (selectedBuiltIn != nil || selectedServer != nil) && !isCreating + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New Document from Template") + .font(.ilTitle(18)) + .padding(.top, 4) + + List(selection: $selection) { + builtInSection + serverSection + } + .listStyle(.inset) + .frame(minHeight: 220) + + if let error = viewModel.error { + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(Color.accentColor) + } + + if let createError = serverTemplates.createError { + Text(createError.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(Color.accentColor) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Create") { + Task { await create() } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canCreate) + } + } + .padding(20) + .frame(minWidth: 440, minHeight: 380) + .onAppear { + // Preselect the first built-in (Blank) so the primary action is + // always live when the sheet opens. + if selection == nil, let first = builtInTemplates.first { + selection = .builtIn(first.id) + } + } + .task { + // Load the server section lazily; failure is non-fatal so the + // built-in section above is unaffected. + await serverTemplates.loadTemplates() + } + } + + // MARK: - Sections + + private var builtInSection: some View { + Section("Built-in") { + ForEach(builtInTemplates) { template in + VStack(alignment: .leading, spacing: 2) { + Text(template.name) + .font(.ilBody()) + .fontWeight(.medium) + Text(template.summary) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + .tag(TemplateSelection.builtIn(template.id)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(template.name). \(template.summary)") + } + } + } + + @ViewBuilder + private var serverSection: some View { + Section("Your templates") { + if serverTemplates.isLoading && serverTemplates.templates.isEmpty { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading your templates…") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } else if let loadError = serverTemplates.loadError { + Text("Couldn't load your templates: \(loadError.localizedDescription)") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .padding(.vertical, 2) + } else if serverTemplates.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("No saved templates yet") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + Button("Seed defaults") { + Task { await serverTemplates.seedDefaults() } + } + .buttonStyle(.link) + .font(.ilMono(10)) + .disabled(serverTemplates.isLoading) + } + .padding(.vertical, 2) + } else { + ForEach(serverTemplates.templates) { template in + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text(template.title) + .font(.ilBody()) + .fontWeight(.medium) + if let path = template.relativePath, !path.isEmpty { + Text(path) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + if serverTemplates.pendingTemplateIDs.contains(template.id) { + Spacer() + ProgressView().controlSize(.small) + } + } + .padding(.vertical, 2) + .tag(TemplateSelection.server(template.id)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(template.title). Your saved template.") + } + } + } + } + + // MARK: - Actions + + private func create() async { + isCreating = true + defer { isCreating = false } + + if let builtIn = selectedBuiltIn { + if let created = await viewModel.createDocument(from: builtIn) { + onCreated(created) + dismiss() + } + // On failure the view model's `error` is surfaced above and the + // sheet stays open so the user can retry or cancel. + } else if let server = selectedServer { + if let created = await serverTemplates.createFromTemplate(server) { + onCreated(created) + dismiss() + } + // On failure the server VM's `createError` is set; the sheet stays + // open. (Surfaced below the list via the error row.) + } + } +} diff --git a/App/Features/Documents/DocumentsListViewModel.swift b/App/Features/Documents/DocumentsListViewModel.swift index a330251..7402ab6 100644 --- a/App/Features/Documents/DocumentsListViewModel.swift +++ b/App/Features/Documents/DocumentsListViewModel.swift @@ -145,6 +145,31 @@ final class DocumentsListViewModel { } } + /// Creates a new document seeded from a client-side `DocumentTemplate` + /// (feature-gaps.md §1.4). The chosen template supplies the starter + /// Markdown body; the title defaults to the template name but the caller + /// may override it, and the user can rename it in the editor afterward. + /// + /// This routes through the same `createDocument(title:body:isPublic:)` path + /// as a blank document — a template is purely a seed, so seeding from + /// `.blank` is identical to today's "new blank document" behavior. There is + /// no templates endpoint; the catalog is bundled in `InterlinedDomain`. If + /// the API later exposes templates this can switch to a server fetch without + /// changing this signature. + @discardableResult + func createDocument( + from template: DocumentTemplate, + title: String? = nil, + isPublic: Bool = false + ) async -> Document? { + let resolvedTitle = title ?? template.name + return await createDocument( + title: resolvedTitle, + body: template.bodyMarkdown, + isPublic: isPublic + ) + } + /// Deletes a document. Optimistic — removes from the rendered list /// first, then calls the service; on failure restores the snapshot /// and surfaces the error. diff --git a/App/Features/Documents/DocumentsRootView.swift b/App/Features/Documents/DocumentsRootView.swift index a6f9e18..b9f0bc9 100644 --- a/App/Features/Documents/DocumentsRootView.swift +++ b/App/Features/Documents/DocumentsRootView.swift @@ -35,6 +35,13 @@ struct DocumentsRootView: View { @State private var editor: DocumentEditorViewModel? @State private var syncStatus: SyncStatusViewModel? + /// Drives the "New from Template…" picker sheet (feature-gaps.md §1.4). + @State private var isTemplatePickerPresented = false + + /// Drives the Share Links panel for the document currently open in the + /// editor (the-gaps.md G3). + @State private var isShareLinksPresented = false + var body: some View { Group { if let environment, let folderTree, let documentsList, let editor, let syncStatus { @@ -55,9 +62,25 @@ struct DocumentsRootView: View { .onReceive(NotificationCenter.default.publisher(for: .documentsNewDocument)) { _ in Task { await handleNewDocument() } } + .onReceive(NotificationCenter.default.publisher(for: .documentsNewFromTemplate)) { _ in + isTemplatePickerPresented = true + } .onReceive(NotificationCenter.default.publisher(for: .documentsSyncNow)) { _ in Task { await syncStatus?.syncNow() } } + .sheet(isPresented: $isTemplatePickerPresented) { + if let documentsList, let environment { + DocumentTemplatePickerView( + viewModel: documentsList, + serverTemplates: ServerTemplatesViewModel( + service: environment.documentTemplatesService, + documentsList: documentsList + ) + ) { created in + editor?.bind(to: created) + } + } + } } @ViewBuilder @@ -104,6 +127,22 @@ struct DocumentsRootView: View { .keyboardShortcut("n", modifiers: [.option, .command]) .help("Create a new document in this folder") + Button { + isTemplatePickerPresented = true + } label: { + Label("New from Template", systemImage: "doc.badge.gearshape") + } + .keyboardShortcut("n", modifiers: [.option, .command, .shift]) + .help("Create a new document from a starter template") + + Button { + isShareLinksPresented = true + } label: { + Label("Share Links", systemImage: "link.badge.plus") + } + .disabled(editor.document == nil) + .help("Create and manage shareable links for this document") + Button { Task { await syncStatus.syncNow() } } label: { @@ -115,6 +154,11 @@ struct DocumentsRootView: View { SyncStatusView(viewModel: syncStatus) } } + .sheet(isPresented: $isShareLinksPresented) { + if let documentID = editor.document?.id { + ShareLinksView(target: .document(id: documentID), environment: environment) + } + } } // MARK: - Setup diff --git a/App/Features/Documents/ServerTemplatesViewModel.swift b/App/Features/Documents/ServerTemplatesViewModel.swift new file mode 100644 index 0000000..7e9d599 --- /dev/null +++ b/App/Features/Documents/ServerTemplatesViewModel.swift @@ -0,0 +1,153 @@ +// ServerTemplatesViewModel +// +// Drives the "Your templates" section of the "New from Template…" picker +// (the-gaps.md G12). Lists the user's own **server-side** saved template +// documents (`DocumentTemplateRef`), creates a new document from one, and can +// seed the default starter set. +// +// This is deliberately separate from the client-side `DocumentTemplate.builtIn` +// catalog the picker already shows: the built-in section is dependency-free and +// stays exactly as-is, while this view model is the only part that touches the +// network. Load failures are therefore **non-fatal** — they are surfaced in a +// dedicated `loadError` so the built-in section always renders regardless. +// +// After `createFromTemplate` (which the server answers `201` with an empty +// body), the new document is materialized on the server, so this view model +// reloads the injected `DocumentsListViewModel` and returns the freshly-created +// `Document` so the caller can bind the editor to it — mirroring how selecting a +// built-in template routes through `createDocument(from:)`. +// +// Decision 0003 compliance: this file consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ServerTemplatesViewModel { + + private let service: DocumentTemplatesServicing + + /// The documents list the picker feeds into. After a server-template + /// create, this view model reloads it so the new document appears, then + /// hands the created document back to the caller to open in the editor. + private let documentsList: DocumentsListViewModel + + // MARK: - Observable state + + /// The user's saved server-side templates, most-recent order as returned + /// by the service. Empty until `loadTemplates()` succeeds (or when the + /// account genuinely has none). + private(set) var templates: [DocumentTemplateRef] = [] + + /// True while the initial `templates()` fetch or a `seedDefaultTemplates()` + /// round-trip is in flight. + private(set) var isLoading: Bool = false + + /// True once a `templates()` fetch has completed (success *or* failure) so + /// the view can distinguish "still loading" from "loaded, but empty" and + /// only show the empty-state row after the first load resolves. + private(set) var hasLoaded: Bool = false + + /// Non-fatal error from the most recent failed `templates()` / + /// `seedDefaultTemplates()` load. Kept separate from the documents-list + /// error so a server-template failure never blocks the built-in section. + private(set) var loadError: Error? + + /// Error from the most recent failed `createFromTemplate`. Surfaced to the + /// picker so the sheet can stay open for a retry. + private(set) var createError: Error? + + /// Debounce set keyed by server-template id so rapid double-taps on the + /// same "Your templates" row don't double-fire the create. Also used by + /// the view to show a per-row in-flight state. + private(set) var pendingTemplateIDs: Set = [] + + /// True when the load has resolved and there are no server templates — + /// the signal the picker uses to show the "No saved templates yet" row. + var isEmpty: Bool { + hasLoaded && templates.isEmpty + } + + // MARK: - Init + + init(service: DocumentTemplatesServicing, documentsList: DocumentsListViewModel) { + self.service = service + self.documentsList = documentsList + } + + // MARK: - Intents + + /// Loads the user's server templates. Non-fatal: on failure the list is + /// left empty and `loadError` is set, but the caller keeps rendering the + /// built-in section. Safe to call repeatedly (e.g. re-open of the sheet). + func loadTemplates() async { + isLoading = true + defer { + isLoading = false + hasLoaded = true + } + do { + templates = try await service.templates() + loadError = nil + } catch { + templates = [] + loadError = error + } + } + + /// Creates a document from the given server template, then reloads the + /// documents list so the new document appears, and returns it so the + /// caller can open it in the editor. Debounced per template id. + /// + /// Returns `nil` (and sets `createError`) on failure, or when the same + /// template already has a create in flight. + @discardableResult + func createFromTemplate(_ template: DocumentTemplateRef) async -> Document? { + guard !pendingTemplateIDs.contains(template.id) else { return nil } + pendingTemplateIDs.insert(template.id) + defer { pendingTemplateIDs.remove(template.id) } + + // Snapshot the currently-loaded document ids so the reload can + // identify the newly-materialized document rather than trusting a + // positional guess. + let existingIDs = Set(documentsList.documentsLoaded.map(\.id)) + do { + try await service.createFromTemplate(templateDocumentId: template.id) + createError = nil + } catch { + createError = error + return nil + } + + // The server created the document with an empty `201` body, so reload + // to surface it, then select + return the newly-appeared one. + await documentsList.refresh() + let created = documentsList.documentsLoaded.first { !existingIDs.contains($0.id) } + ?? documentsList.documentsLoaded.first + if let created { + documentsList.select(id: created.id) + } + return created + } + + /// Seeds the account's default starter templates, then reloads the list so + /// the freshly-seeded templates appear in the "Your templates" section. + /// Non-fatal like `loadTemplates` — a failure sets `loadError` and leaves + /// the built-in section intact. + func seedDefaults() async { + isLoading = true + do { + try await service.seedDefaultTemplates() + loadError = nil + } catch { + isLoading = false + loadError = error + return + } + // Re-fetch to show what was seeded. `loadTemplates` manages its own + // `isLoading`/`hasLoaded`, so hand off directly. + await loadTemplates() + } +} diff --git a/App/Features/Exports/ExportView.swift b/App/Features/Exports/ExportView.swift index f72107b..f4e9388 100644 --- a/App/Features/Exports/ExportView.swift +++ b/App/Features/Exports/ExportView.swift @@ -45,7 +45,7 @@ struct ExportView: View { } .task { guard viewModel == nil, let environment else { return } - let vm = ExportViewModel(exportsService: environment.exportsService) + let vm = ExportViewModel(exportsService: environment.exportsService, lists: environment.lists) viewModel = vm if let initialExportType { vm.export(initialExportType) @@ -123,6 +123,20 @@ private struct ExportContentView: View { viewModel.errorMessage = error.localizedDescription } } + .fileExporter( + isPresented: Binding( + get: { viewModel.pendingMarkdownExport != nil }, + set: { if !$0 { viewModel.pendingMarkdownExport = nil } } + ), + document: MarkdownFileDocument(viewModel.pendingMarkdownExport?.text), + contentType: .markdownText, + defaultFilename: viewModel.pendingMarkdownExport?.filename ?? "export" + ) { result in + viewModel.pendingMarkdownExport = nil + if case .failure(let error) = result { + viewModel.errorMessage = error.localizedDescription + } + } } } @@ -143,6 +157,15 @@ private struct ExportRowView: View { .foregroundStyle(.secondary) } Spacer() + if type == .lists { + // Lists also export as Markdown tables ("structured table + // conversion"); other types remain CSV-only for now. + Button("Markdown…") { + viewModel.exportListsAsMarkdown() + } + .disabled(viewModel.isExporting) + .accessibilityLabel("Export My Lists as Markdown") + } Button("Export CSV…") { viewModel.export(type) } @@ -185,3 +208,42 @@ struct ExportDocument: FileDocument { FileWrapper(regularFileWithContents: data) } } + +// MARK: - MarkdownFileDocument + +/// A `FileDocument` wrapping a rendered Markdown string for the `.fileExporter` +/// save panel — the AppKit-free way to save the client-composed Markdown export +/// (feature-gaps.md §1.3) without an `NSSavePanel`. +struct MarkdownFileDocument: FileDocument { + static let readableContentTypes: [UTType] = [.markdownText, .plainText] + + let text: String + + /// Accepts `nil` so the `.fileExporter`'s `document:` parameter is always + /// satisfied even before a Markdown export is pending; `isPresented` gates + /// when the dialog actually appears. + init(_ text: String?) { + self.text = text ?? "" + } + + init(configuration: ReadConfiguration) throws { + if let data = configuration.file.regularFileContents { + self.text = String(decoding: data, as: UTF8.self) + } else { + self.text = "" + } + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(text.utf8)) + } +} + +extension UTType { + /// Markdown (`.md`). Resolves to the system-declared `net.daringfireball.markdown` + /// on macOS 15, falling back to a dynamic type conforming to plain text so the + /// save panel still applies a `.md` extension. + static var markdownText: UTType { + UTType(filenameExtension: "md", conformingTo: .plainText) ?? .plainText + } +} diff --git a/App/Features/Exports/ExportViewModel.swift b/App/Features/Exports/ExportViewModel.swift index 7443c7c..a3f1f7e 100644 --- a/App/Features/Exports/ExportViewModel.swift +++ b/App/Features/Exports/ExportViewModel.swift @@ -63,6 +63,19 @@ import InterlinedDomain /// clears it when the dialog resolves (success or cancel). var pendingExport: CSVExport? = nil + /// A rendered Markdown export waiting to be saved (feature-gaps.md §1.3). + /// The `/api/exports/*` endpoints are CSV-only, so Markdown is composed + /// client-side from domain models via `MarkdownExporter` (see + /// `feature-blockages.md` P2-F for the server-side ask). Drives a second + /// `.fileExporter` in the view. + var pendingMarkdownExport: MarkdownExport? = nil + + /// A rendered Markdown document plus the filename stem for the save panel. + struct MarkdownExport: Equatable { + let filename: String + let text: String + } + /// Non-nil when a service call failed. Displayed as an amber-tinted /// banner in `ExportView`. Cleared at the start of the next export /// attempt. @@ -71,9 +84,17 @@ import InterlinedDomain // MARK: - Init private let exportsService: ExportsServicing + private let lists: ListsServicing + private let markdownExporter = MarkdownExporter() + + /// Page sizes for the bulk Markdown-lists fetch. Kept modest so a large + /// account paginates rather than requesting everything at once. + private static let listsPageSize = 50 + private static let rowsPageSize = 100 - init(exportsService: ExportsServicing) { + init(exportsService: ExportsServicing, lists: ListsServicing) { self.exportsService = exportsService + self.lists = lists } // MARK: - Intent @@ -111,4 +132,71 @@ import InterlinedDomain } } } + + /// Exports all of the user's owned lists as a single Markdown document + /// ("structured table conversion" — feature-gaps.md §1.3). Fetches every + /// owned list and its rows client-side (the export API is CSV-only), then + /// renders them with `MarkdownExporter`. No-op while another export is in + /// flight. On success `pendingMarkdownExport` becomes non-nil and the view + /// triggers the save panel; on failure `errorMessage` is populated. + func exportListsAsMarkdown() { + guard !isExporting else { return } + isExporting = true + activeExport = .lists + errorMessage = nil + + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isExporting = false } + do { + let inputs = try await self.collectListInputs() + let text = self.markdownExporter.markdown(forLists: inputs) + self.pendingMarkdownExport = MarkdownExport( + filename: "interlinedlist-lists", + text: text + ) + } catch { + self.errorMessage = error.localizedDescription + } + } + } + + /// Pages through every owned list, pairing each with its fully-paginated + /// rows, and projects them into `MarkdownExporter.ListInput` values. + private func collectListInputs() async throws -> [MarkdownExporter.ListInput] { + var inputs: [MarkdownExporter.ListInput] = [] + var offset = 0 + while true { + let page = try await lists.myLists(limit: Self.listsPageSize, offset: offset) + for list in page.lists { + let rows = try await collectRows(listId: list.id) + inputs.append( + MarkdownExporter.ListInput( + title: list.title, + description: list.description, + schemaDSL: list.schemaDescription, + rows: rows + ) + ) + } + // Stop when the server reports no more pages, or when the cursor + // fails to advance (defensive: never spin on a stuck offset). + guard page.hasMore, let next = page.nextOffset, next > offset else { break } + offset = next + } + return inputs + } + + /// Pages through every row of one owned list. + private func collectRows(listId: String) async throws -> [ListRow] { + var rows: [ListRow] = [] + var offset = 0 + while true { + let page = try await lists.rows(of: listId, limit: Self.rowsPageSize, offset: offset) + rows.append(contentsOf: page.rows) + guard page.hasMore, let next = page.nextOffset, next > offset else { break } + offset = next + } + return rows + } } diff --git a/App/Features/Lists/ListFoldersSectionView.swift b/App/Features/Lists/ListFoldersSectionView.swift new file mode 100644 index 0000000..34ab96b --- /dev/null +++ b/App/Features/Lists/ListFoldersSectionView.swift @@ -0,0 +1,219 @@ +// ListFoldersSectionView +// +// The folder-tree section for the Lists sidebar (the-gaps.md G6). A +// self-contained section that owns its own `ListFoldersViewModel`, loads +// the tree on appear, and renders the nested folders with create / +// rename / move / delete affordances. Designed to be dropped into the +// existing `OwnedListsRootView` sidebar `List` above the lists rows so +// the change stays additive. +// +// Subscriber gate: folder creation is subscriber-only. When a create is +// blocked, the view model raises `showSubscriberUpsell`; this view +// presents a small upsell alert instead of an error, matching the +// domain's "disabled / upsell, not an error" contract. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct ListFoldersSectionView: View { + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: ListFoldersViewModel? + + // Sheet / dialog state for the mutation affordances. + @State private var showNewFolderPrompt = false + @State private var newFolderName = "" + @State private var newFolderParentID: String? + + @State private var renameTarget: ListFolder? + @State private var renameName = "" + + @State private var deleteTargetID: String? + + var body: some View { + Section("Folders") { + if let viewModel { + folderList(viewModel: viewModel) + } else { + ProgressView() + .accessibilityLabel("Loading folders") + .frame(maxWidth: .infinity) + } + } + .task { + if viewModel == nil, let environment { + let vm = ListFoldersViewModel(service: environment.listFolders) + viewModel = vm + await vm.load() + } + } + // New-folder name prompt. `newFolderParentID` selects the parent + // (nil = root); "New Subfolder" context-menu items set it first. + .alert("New folder", isPresented: $showNewFolderPrompt) { + TextField("Folder name", text: $newFolderName) + Button("Create") { + let name = newFolderName + let parent = newFolderParentID + newFolderName = "" + newFolderParentID = nil + if let viewModel { + Task { await viewModel.create(name: name, parentId: parent) } + } + } + Button("Cancel", role: .cancel) { + newFolderName = "" + newFolderParentID = nil + } + } + // Rename prompt. + .alert("Rename folder", isPresented: Binding( + get: { renameTarget != nil }, + set: { if !$0 { renameTarget = nil } } + )) { + TextField("Folder name", text: $renameName) + Button("Rename") { + if let target = renameTarget, let viewModel { + let name = renameName + Task { await viewModel.rename(id: target.id, to: name) } + } + renameTarget = nil + } + Button("Cancel", role: .cancel) { renameTarget = nil } + } + // Delete confirmation. + .confirmationDialog( + "Delete this folder?", + isPresented: Binding( + get: { deleteTargetID != nil }, + set: { if !$0 { deleteTargetID = nil } } + ), + presenting: deleteTargetID + ) { id in + Button("Delete", role: .destructive) { + if let viewModel { Task { await viewModel.delete(id: id) } } + deleteTargetID = nil + } + Button("Cancel", role: .cancel) { deleteTargetID = nil } + } message: { _ in + Text("Lists inside the folder are not deleted; they return to the top level.") + } + // Subscriber upsell when a create is gated. + .alert("Subscribe to use folders", isPresented: Binding( + get: { viewModel?.showSubscriberUpsell ?? false }, + set: { if !$0 { viewModel?.dismissUpsell() } } + )) { + Button("OK", role: .cancel) { viewModel?.dismissUpsell() } + } message: { + Text("Organizing lists into folders is a subscriber feature.") + } + } + + // MARK: - Folder list + + @ViewBuilder + private func folderList(viewModel: ListFoldersViewModel) -> some View { + // "New folder at root" affordance always available. + Button { + newFolderParentID = nil + showNewFolderPrompt = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + + if let error = viewModel.error { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + } + + if viewModel.tree.isEmpty, viewModel.hasLoadedOnce { + Text("No folders yet.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.tree) { node in + FolderTreeRow( + node: node, + onNewSubfolder: { parentID in + newFolderParentID = parentID + showNewFolderPrompt = true + }, + onRename: { folder in + renameName = folder.name + renameTarget = folder + }, + onDelete: { id in deleteTargetID = id } + ) + } + } + } +} + +// MARK: - FolderTreeRow + +/// Recursive folder row rendering a `ListFolderNode` with a disclosure +/// group when it has children. Each row carries a context menu with the +/// create-subfolder / rename / delete actions. +private struct FolderTreeRow: View { + let node: ListFolderNode + let onNewSubfolder: (String) -> Void + let onRename: (ListFolder) -> Void + let onDelete: (String) -> Void + + var body: some View { + Group { + if node.children.isEmpty { + row + } else { + DisclosureGroup { + ForEach(node.children) { child in + FolderTreeRow( + node: child, + onNewSubfolder: onNewSubfolder, + onRename: onRename, + onDelete: onDelete + ) + } + } label: { + row + } + } + } + } + + private var row: some View { + HStack(spacing: 6) { + Image(systemName: "folder") + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + Text(node.name) + .lineLimit(1) + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Folder \(node.name)") + .contextMenu { + Button { + onNewSubfolder(node.id) + } label: { + Label("New Subfolder", systemImage: "folder.badge.plus") + } + Button { + onRename(node.folder) + } label: { + Label("Rename\u{2026}", systemImage: "pencil") + } + Divider() + Button(role: .destructive) { + onDelete(node.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } +} diff --git a/App/Features/Lists/ListFoldersViewModel.swift b/App/Features/Lists/ListFoldersViewModel.swift new file mode 100644 index 0000000..e944231 --- /dev/null +++ b/App/Features/Lists/ListFoldersViewModel.swift @@ -0,0 +1,184 @@ +// ListFoldersViewModel +// +// Drives the Lists-sidebar folder tree (the-gaps.md G6). Owns the loaded +// `[ListFolderNode]` tree, the loading / error state, and the create / +// rename / move / delete actions. Reads through `ListFoldersServicing` +// only — no direct API or entitlements access — so unit tests substitute +// a stub service. +// +// Subscriber gate: folder *creation* is subscriber-only. The domain +// service throws `ListFoldersError.subscriberRequired` before any HTTP +// when the account is not a subscriber. This view model catches that +// specific case and raises `showSubscriberUpsell` instead of surfacing a +// raw error, so the view can present an upsell / disabled affordance +// rather than an error banner. Every other failure flows into `error`. +// +// Mutations refresh the tree from the service's authoritative return +// (create / rename / move return the updated `ListFolder`; the tree is +// rebuilt from a fresh `folders()` fetch so nesting stays correct without +// the view model re-implementing tree assembly). Delete uses optimistic +// removal with snapshot rollback on failure. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ListFoldersViewModel { + + // MARK: - Dependencies + + private let service: ListFoldersServicing + + // MARK: - Observable state + + /// The assembled folder tree, roots first. + private(set) var tree: [ListFolderNode] = [] + + /// True while a load / mutation round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed load / rename / move / + /// delete. The subscriber-gate case is routed to + /// `showSubscriberUpsell` instead, so this never carries a + /// `subscriberRequired`. + private(set) var error: Error? + + /// Raised when a create was blocked because the account is not a + /// subscriber. The view presents an upsell / disabled state. Reset by + /// `dismissUpsell()` or the next successful create. + private(set) var showSubscriberUpsell: Bool = false + + /// True once the first load has resolved (success or failure). + private(set) var hasLoadedOnce: Bool = false + + /// Per-folder debounce set so a rapid double-tap on delete doesn't + /// double-fire the service call. + private var pendingOperations: Set = [] + + // MARK: - Init + + init(service: ListFoldersServicing) { + self.service = service + } + + // MARK: - Intents + + /// First-time + refresh load. Replaces the rendered tree. + func load() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + tree = try await service.tree() + error = nil + hasLoadedOnce = true + } catch { + self.error = error + hasLoadedOnce = true + } + } + + /// Creates a folder under `parentId` (or at root when nil), then + /// refreshes the tree. A `subscriberRequired` failure raises the + /// upsell instead of an error; an `invalidName` (or any other) failure + /// flows into `error`. Returns the created folder on success so the + /// caller can, e.g., select it. + @discardableResult + func create(name: String, parentId: String? = nil) async -> ListFolder? { + showSubscriberUpsell = false + do { + let folder = try await service.create(name: name, parentId: parentId) + await reloadTree() + error = nil + return folder + } catch ListFoldersError.subscriberRequired { + showSubscriberUpsell = true + return nil + } catch { + self.error = error + return nil + } + } + + /// Renames a folder, then refreshes the tree. + func rename(id: String, to name: String) async { + do { + _ = try await service.rename(id: id, name: name) + await reloadTree() + error = nil + } catch { + self.error = error + } + } + + /// Moves a folder under a new parent (or to root when nil), then + /// refreshes the tree. + func move(id: String, toParent parentId: String?) async { + do { + _ = try await service.move(id: id, toParent: parentId) + await reloadTree() + error = nil + } catch { + self.error = error + } + } + + /// Deletes a folder. Optimistic: prune the subtree locally, call the + /// service, restore the snapshot on failure. + func delete(id: String) async { + guard !pendingOperations.contains(id) else { return } + pendingOperations.insert(id) + defer { pendingOperations.remove(id) } + let snapshot = tree + tree = Self.pruning(id: id, from: tree) + do { + try await service.delete(id: id) + error = nil + } catch { + tree = snapshot + self.error = error + } + } + + /// Dismisses the subscriber upsell (the "Upgrade" sheet was shown / + /// cancelled). + func dismissUpsell() { + showSubscriberUpsell = false + } + + /// Convenience for tests + previews — seed the rendered tree without + /// going through the service. + func seedForTest(tree: [ListFolderNode]) { + self.tree = tree + self.hasLoadedOnce = true + } + + // MARK: - Internals + + /// Refetches and reassembles the tree after a mutation. Failures here + /// surface as `error` but do not undo the mutation (the server already + /// applied it; a subsequent `load()` reconciles). + private func reloadTree() async { + do { + tree = try await service.tree() + } catch { + self.error = error + } + } + + /// Returns `nodes` with the node identified by `id` (and its subtree) + /// removed. Pure; used for the optimistic delete prune. + private static func pruning(id: String, from nodes: [ListFolderNode]) -> [ListFolderNode] { + nodes.compactMap { node in + guard node.id != id else { return nil } + return ListFolderNode( + folder: node.folder, + children: pruning(id: id, from: node.children) + ) + } + } +} diff --git a/App/Features/Lists/ListRowsView.swift b/App/Features/Lists/ListRowsView.swift index 5d5b470..d8fe95e 100644 --- a/App/Features/Lists/ListRowsView.swift +++ b/App/Features/Lists/ListRowsView.swift @@ -87,46 +87,61 @@ struct ListRowsView: View { @ViewBuilder private func tableMode(viewModel: ListRowsViewModel) -> some View { - // Dynamic-column `TableColumnForEach` is macOS 14.4+, the - // project targets 14.0. Fall back to a `List` of cells until - // the deployment target moves — the data shape and the - // selection/delete plumbing are identical to a real table. - List(selection: $selection) { - ForEach(viewModel.rows) { row in - rowSummary(row: row, columns: viewModel.columns) - .tag(row.id) - .onAppear { - if shouldLoadMore(row, in: viewModel.rows) { - Task { await viewModel.loadMore() } - } + // Real SwiftUI `Table` with one typed column per schema field. The + // dynamic-column `TableColumnForEach` needs macOS 14.4+; the app now + // targets macOS 15, so the earlier `List`-of-cells fallback is retired. + // `Table` has no per-row appearance hook, so pagination is a "Load more" + // footer here (cards mode keeps scroll-to-load). + let columns = effectiveColumns(viewModel) + VStack(spacing: 0) { + Table(viewModel.rows, selection: $selection) { + TableColumnForEach(columns, id: \.self) { column in + TableColumn(column) { (row: ListRow) in + Text(row.fields[column]?.displayText ?? "") + .lineLimit(2) } + } + } + .onChange(of: selection) { _, newSelection in + // Sync single-selection back into the view model so the + // RowInspector can render. + viewModel.selectedRowID = newSelection.first + } + + if viewModel.hasMore { + Divider() + Button { + Task { await viewModel.loadMore() } + } label: { + if viewModel.isLoading { + ProgressView().controlSize(.small) + } else { + Text("Load More Rows") + } + } + .buttonStyle(.borderless) + .disabled(viewModel.isLoading) + .padding(8) + .frame(maxWidth: .infinity) + .accessibilityLabel("Load more rows") } - } - .onChange(of: selection) { _, newSelection in - // Sync single-selection back into the view model so the - // RowInspector can render. - viewModel.selectedRowID = newSelection.first } } - @ViewBuilder - private func rowSummary(row: ListRow, columns: [String]) -> some View { - let keys = columns.isEmpty ? row.fields.keys.sorted() : columns - HStack(alignment: .firstTextBaseline, spacing: 12) { - ForEach(keys, id: \.self) { key in - VStack(alignment: .leading, spacing: 1) { - Text(key) - .font(.ilMono(9)) - .foregroundStyle(.secondary) - Text(row.fields[key]?.displayText ?? "") - .font(.ilBody()) - .lineLimit(2) - } - .frame(minWidth: 100, alignment: .leading) + /// Ordered column set for the table: the schema-derived columns when + /// present, else the sorted union of keys across loaded rows so a + /// schemaless list still renders a sensible grid. + private func effectiveColumns(_ viewModel: ListRowsViewModel) -> [String] { + if !viewModel.columns.isEmpty { return viewModel.columns } + var seen = Set() + var ordered: [String] = [] + for row in viewModel.rows { + for key in row.fields.keys.sorted() where !seen.contains(key) { + seen.insert(key) + ordered.append(key) } - Spacer() } - .accessibilityElement(children: .combine) + return ordered } @ViewBuilder diff --git a/App/Features/Lists/ListRowsViewModel.swift b/App/Features/Lists/ListRowsViewModel.swift index e325514..ade82b1 100644 --- a/App/Features/Lists/ListRowsViewModel.swift +++ b/App/Features/Lists/ListRowsViewModel.swift @@ -213,7 +213,11 @@ final class ListRowsViewModel { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return .null } switch type { - case .text, .url, .email, .date: + case .text, .url, .email, .date, .select, .markdown: + // `select` stores the chosen option's raw text; `markdown` + // stores raw Markdown source. Both are string-valued cells — + // the option-set constraint is enforced by the picker UI, not + // here (this helper also drives free-text entry). return .string(trimmed) case .number: if let intValue = Int(trimmed) { return .int(intValue) } diff --git a/App/Features/Lists/OwnedListsRootView.swift b/App/Features/Lists/OwnedListsRootView.swift index 74c3e7b..2c08053 100644 --- a/App/Features/Lists/OwnedListsRootView.swift +++ b/App/Features/Lists/OwnedListsRootView.swift @@ -27,6 +27,7 @@ struct OwnedListsRootView: View { @State private var showsNewListSheet: Bool = false @State private var showsSchemaEditor: Bool = false @State private var showsWatchers: Bool = false + @State private var showsShareLinks: Bool = false @State private var showsConnections: Bool = false @State private var listIDPendingDelete: String? @@ -118,9 +119,18 @@ struct OwnedListsRootView: View { Button { showsWatchers = true } label: { - Label("Share", systemImage: "person.2") + Label("Watchers", systemImage: "person.2") } .disabled(viewModel.selectedListID == nil) + .help("Manage who can see and edit this list") + + Button { + showsShareLinks = true + } label: { + Label("Share Links", systemImage: "link.badge.plus") + } + .disabled(viewModel.selectedListID == nil) + .help("Create and manage shareable links for this list") Button { showsConnections = true @@ -148,6 +158,11 @@ struct OwnedListsRootView: View { WatchersView(listId: listId, environment: environment) } } + .sheet(isPresented: $showsShareLinks) { + if let environment, let listId = viewModel.selectedListID { + ShareLinksView(target: .list(id: listId), environment: environment) + } + } .sheet(isPresented: $showsConnections) { if let environment, let listId = viewModel.selectedListID { ListConnectionsView( @@ -185,6 +200,11 @@ struct OwnedListsRootView: View { get: { viewModel.selectedListID }, set: { viewModel.select(id: $0) } )) { + // Web-parity (the-gaps.md G6) — folder tree above the lists. + // Self-contained section that owns its own folders view model. + ListFoldersSectionView() + + Section("Lists") { if viewModel.lists_loaded.isEmpty, viewModel.isLoading { ProgressView() .accessibilityLabel("Loading lists") @@ -215,6 +235,7 @@ struct OwnedListsRootView: View { ) } } + } // Section("Lists") } .listStyle(.sidebar) .navigationSplitViewColumnWidth(min: 220, ideal: 260) diff --git a/App/Features/Lists/RowInspectorView.swift b/App/Features/Lists/RowInspectorView.swift index 13fc050..7e7a76d 100644 --- a/App/Features/Lists/RowInspectorView.swift +++ b/App/Features/Lists/RowInspectorView.swift @@ -12,6 +12,7 @@ import SwiftUI import InterlinedDomain +import Textual struct RowInspectorView: View { @@ -41,6 +42,7 @@ struct RowInspectorView: View { cellEditor( key: key, type: .text, + options: [], current: row.fields[key] ?? .null, row: row, viewModel: viewModel @@ -51,6 +53,7 @@ struct RowInspectorView: View { cellEditor( key: field.name, type: field.type, + options: field.enumValues ?? [], current: row.fields[field.name] ?? .null, row: row, viewModel: viewModel @@ -66,6 +69,7 @@ struct RowInspectorView: View { private func cellEditor( key: String, type: SchemaFieldType, + options: [String], current: ListCellValue, row: ListRow, viewModel: ListRowsViewModel @@ -99,6 +103,91 @@ struct RowInspectorView: View { } )) .accessibilityLabel(key) + case .select: + selectEditor( + key: key, + options: options, + current: current, + row: row, + viewModel: viewModel + ) + case .markdown: + markdownEditor( + key: key, + current: current, + row: row, + viewModel: viewModel + ) + } + } + } + + /// A `Picker` constrained to the column's option set. The stored value is + /// the chosen option's raw text; committing routes through the same + /// `updateRow` path as every other cell. A leading empty tag models "no + /// selection" so a nullable select can be cleared. + @ViewBuilder + private func selectEditor( + key: String, + options: [String], + current: ListCellValue, + row: ListRow, + viewModel: ListRowsViewModel + ) -> some View { + Picker("", selection: Binding( + get: { editingValues[key] ?? current.displayText }, + set: { newValue in + editingValues[key] = newValue + commitChange(row: row, key: key, type: .select, viewModel: viewModel) + } + )) { + Text("—").tag("") + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityLabel(key) + } + + /// An editable multiline field previewed with the same `Textual` renderer + /// Documents uses (`StructuredText(markdown:)`). Edits are buffered in + /// `editingValues` and committed on the explicit "Save" control — a + /// `TextEditor` has no `onSubmit`, and auto-committing every keystroke + /// would fire a write per character. + @ViewBuilder + private func markdownEditor( + key: String, + current: ListCellValue, + row: ListRow, + viewModel: ListRowsViewModel + ) -> some View { + let source = editingValues[key] ?? current.displayText + VStack(alignment: .leading, spacing: 6) { + TextEditor(text: Binding( + get: { editingValues[key] ?? current.displayText }, + set: { editingValues[key] = $0 } + )) + .font(.ilMono(12)) + .frame(minHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.secondary.opacity(0.3)) + ) + .accessibilityLabel(key) + if !source.isEmpty { + StructuredText(markdown: source) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel("\(key) preview") + } + HStack { + Spacer() + Button("Save") { + commitChange(row: row, key: key, type: .markdown, viewModel: viewModel) + } + .controlSize(.small) + .disabled(editingValues[key] == nil) } } } @@ -138,6 +227,8 @@ struct RowInspectorView: View { case .date: return "ISO-8601 date" case .url: return "URL" case .email: return "Email" + case .select: return "Select" + case .markdown: return "Markdown" } } diff --git a/App/Features/Lists/SchemaEditorView.swift b/App/Features/Lists/SchemaEditorView.swift index cdc7bc7..d50e1c8 100644 --- a/App/Features/Lists/SchemaEditorView.swift +++ b/App/Features/Lists/SchemaEditorView.swift @@ -159,6 +159,9 @@ struct SchemaEditorView: View { .accessibilityLabel("Remove field") } } + if field.type == .select { + selectOptionsEditor(viewModel: viewModel, field: field) + } if let error = viewModel.validationError(for: field) { Text(error) .font(.ilMono(10)) @@ -167,6 +170,52 @@ struct SchemaEditorView: View { } } + /// Inline, per-option editor shown only for `select` columns. Each option + /// is an editable text field with a remove button; a trailing "Add option" + /// button appends a blank option. All mutations route through the view + /// model so the option array stays owned there. + @ViewBuilder + private func selectOptionsEditor( + viewModel: SchemaEditorViewModel, + field: SchemaEditorViewModel.EditableField + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Options") + .font(.ilMono(9)) + .foregroundStyle(.secondary) + ForEach(Array(field.options.enumerated()), id: \.offset) { index, option in + HStack(spacing: 6) { + TextField("Option", text: Binding( + get: { option }, + set: { viewModel.setOption($0, forFieldID: field.id, at: index) } + )) + .textFieldStyle(.roundedBorder) + .disabled(!viewModel.isEditable) + if viewModel.isEditable { + Button { + viewModel.removeOption(fromFieldID: field.id, at: index) + } label: { + Image(systemName: "minus.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove option") + } + } + } + if viewModel.isEditable { + Button { + viewModel.addOption(toFieldID: field.id) + } label: { + Label("Add option", systemImage: "plus") + .font(.ilMono(10)) + } + .buttonStyle(.plain) + } + } + .padding(.leading, 8) + } + @ViewBuilder private func footer(viewModel: SchemaEditorViewModel) -> some View { HStack { @@ -240,6 +289,8 @@ struct SchemaEditorView: View { case .date: return "Date" case .url: return "URL" case .email: return "Email" + case .select: return "Select" + case .markdown: return "Markdown" } } } diff --git a/App/Features/Lists/SchemaEditorViewModel.swift b/App/Features/Lists/SchemaEditorViewModel.swift index bc4980b..b3d1c63 100644 --- a/App/Features/Lists/SchemaEditorViewModel.swift +++ b/App/Features/Lists/SchemaEditorViewModel.swift @@ -37,12 +37,23 @@ final class SchemaEditorViewModel { var name: String var type: SchemaFieldType var nullable: Bool - - init(id: UUID = UUID(), name: String, type: SchemaFieldType, nullable: Bool = false) { + /// Ordered option set for `select` columns. Ignored for every other + /// type. Kept as `[String]` (not `Set`) so declaration order — which + /// the picker preserves — is authoritative. + var options: [String] + + init( + id: UUID = UUID(), + name: String, + type: SchemaFieldType, + nullable: Bool = false, + options: [String] = [] + ) { self.id = id self.name = name self.type = type self.nullable = nullable + self.options = options } } @@ -77,7 +88,12 @@ final class SchemaEditorViewModel { self.listId = listId self.role = role self.fields = initialSchema.fields.map { - EditableField(name: $0.name, type: $0.type, nullable: $0.nullable ?? false) + EditableField( + name: $0.name, + type: $0.type, + nullable: $0.nullable ?? false, + options: $0.enumValues ?? [] + ) } } @@ -102,10 +118,38 @@ final class SchemaEditorViewModel { /// Sets the type of a field by row id. Convenience used by the /// view's per-row Picker so the binding is one-way through the - /// view model. + /// view model. Switching away from `select` discards its options so a + /// later switch back starts clean and a non-select never carries a + /// stale option set into `save()`. func setType(_ type: SchemaFieldType, forFieldID id: UUID) { guard let index = fields.firstIndex(where: { $0.id == id }) else { return } fields[index].type = type + if !type.carriesOptions { + fields[index].options = [] + } + } + + /// Appends a new empty option to a `select` field. The view focuses the + /// new option's text field for editing. + func addOption(toFieldID id: UUID) { + guard let index = fields.firstIndex(where: { $0.id == id }), + fields[index].type.carriesOptions else { return } + fields[index].options.append("") + } + + /// Removes the option at `offset` from a `select` field. + func removeOption(fromFieldID id: UUID, at offset: Int) { + guard let index = fields.firstIndex(where: { $0.id == id }), + fields[index].options.indices.contains(offset) else { return } + fields[index].options.remove(at: offset) + } + + /// Sets the text of a single option by index. Bound one-way from the + /// per-option text field so the array stays owned by the view model. + func setOption(_ value: String, forFieldID id: UUID, at offset: Int) { + guard let index = fields.firstIndex(where: { $0.id == id }), + fields[index].options.indices.contains(offset) else { return } + fields[index].options[offset] = value } /// Validates a single field. Returns `nil` when valid, otherwise @@ -124,6 +168,26 @@ final class SchemaEditorViewModel { if duplicates.count > 1 { return "Duplicate field name." } + if field.type.carriesOptions { + return selectOptionError(for: field) + } + return nil + } + + /// Validates a `select` field's option set, mirroring the DSL parser's + /// `.emptySelectOptions` / `.duplicateSelectOption` rules so the editor + /// rejects the same inputs the serializer would round-trip into an + /// invalid schema. Returns `nil` when the options are valid. + private func selectOptionError(for field: EditableField) -> String? { + let trimmedOptions = field.options.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + if trimmedOptions.isEmpty || trimmedOptions.contains(where: \.isEmpty) { + return "Select needs at least one non-empty option." + } + if Set(trimmedOptions).count != trimmedOptions.count { + return "Select options must be unique." + } return nil } @@ -145,11 +209,16 @@ final class SchemaEditorViewModel { error = nil defer { isSaving = false } - let schema = ListSchema(fields: fields.map { + let schema = ListSchema(fields: fields.map { field in SchemaField( - name: $0.name.trimmingCharacters(in: .whitespacesAndNewlines), - type: $0.type, - nullable: $0.nullable + name: field.name.trimmingCharacters(in: .whitespacesAndNewlines), + type: field.type, + nullable: field.nullable, + // Only `select` carries options into the DSL; every other + // type serializes as a bare token. + enumValues: field.type.carriesOptions + ? field.options.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + : nil ) }) do { diff --git a/App/Features/Moderation/BlockedAndMutedView.swift b/App/Features/Moderation/BlockedAndMutedView.swift new file mode 100644 index 0000000..79cee4b --- /dev/null +++ b/App/Features/Moderation/BlockedAndMutedView.swift @@ -0,0 +1,151 @@ +// BlockedAndMutedView +// +// Settings "Blocked & Muted" pane (the-gaps.md G2). Lists the accounts the +// current user has blocked and muted, each with an inline unblock / unmute +// action. A thin shell over `BlockedAndMutedViewModel`: it observes state, +// dispatches user intents, and leaves all loading / mutation logic in the +// view model so unit tests cover the behavior without touching SwiftUI. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct BlockedAndMutedView: View { + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: BlockedAndMutedViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel: viewModel) + } else { + unconfiguredState + } + } + .task { + if viewModel == nil, let environment { + let vm = BlockedAndMutedViewModel(service: environment.moderation) + viewModel = vm + await vm.load() + } + } + } + + // MARK: - Content + + @ViewBuilder + private func content(viewModel: BlockedAndMutedViewModel) -> some View { + Form { + if let error = viewModel.error { + Section { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + Button("Try again") { + Task { await viewModel.load() } + } + } + } + + Section("Blocked") { + if viewModel.blocked.isEmpty { + emptyRow(text: "You haven't blocked anyone.") + } else { + ForEach(viewModel.blocked) { user in + moderatedRow(user: user, actionTitle: "Unblock") { + if let username = user.username { + Task { await viewModel.unblock(username: username) } + } + } + } + } + } + + Section("Muted") { + if viewModel.muted.isEmpty { + emptyRow(text: "You haven't muted anyone.") + } else { + ForEach(viewModel.muted) { user in + moderatedRow(user: user, actionTitle: "Unmute") { + if let username = user.username { + Task { await viewModel.unmute(username: username) } + } + } + } + } + } + } + .formStyle(.grouped) + .overlay { + if viewModel.isLoading, !viewModel.hasLoadedOnce { + ProgressView("Loading…") + } + } + } + + // MARK: - Rows + + @ViewBuilder + private func moderatedRow( + user: ModeratedUser, + actionTitle: String, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: 12) { + avatar(user: user) + VStack(alignment: .leading, spacing: 2) { + Text(user.name) + .font(.body.weight(.medium)) + .lineLimit(1) + Text(user.handle) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button(actionTitle, action: action) + .buttonStyle(.bordered) + // Only actionable when we know the username to send. + .disabled(user.username == nil) + .accessibilityLabel("\(actionTitle) \(user.handle)") + } + .padding(.vertical, 2) + } + + private func avatar(user: ModeratedUser) -> some View { + AsyncImage(url: user.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: 28, height: 28) + .clipShape(Circle()) + .accessibilityHidden(true) + } + + private func emptyRow(text: String) -> some View { + Text(text) + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + } + + private var unconfiguredState: some View { + VStack(spacing: 8) { + Image(systemName: "wrench.adjustable") + .font(.ilDisplay(36)) + .foregroundStyle(.secondary) + Text("Moderation unavailable") + .font(.ilSubtitle()) + Text("AppEnvironment is not injected into the view tree.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/App/Features/Moderation/BlockedAndMutedViewModel.swift b/App/Features/Moderation/BlockedAndMutedViewModel.swift new file mode 100644 index 0000000..7a8a0f5 --- /dev/null +++ b/App/Features/Moderation/BlockedAndMutedViewModel.swift @@ -0,0 +1,123 @@ +// BlockedAndMutedViewModel +// +// Drives `BlockedAndMutedView`: the Settings "Blocked & Muted" pane +// (the-gaps.md G2). Owns the two rendered rosters (blocked / muted), the +// loading / error state, and the unblock / unmute actions. Reads through +// `ModerationServicing` only — no direct API access — so unit tests +// substitute a stub service. +// +// Unblock / unmute use the project's optimistic-removal pattern (mirrors +// `OwnedListsViewModel.deleteList`): snapshot the roster, remove the row +// locally, call the service, and on failure restore the snapshot and +// surface the error. A per-username debounce set guards against a rapid +// double tap firing the service twice for the same row. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class BlockedAndMutedViewModel { + + // MARK: - Dependencies + + private let service: ModerationServicing + + // MARK: - Observable state + + /// Accounts the current user has blocked, in server order. + private(set) var blocked: [ModeratedUser] = [] + + /// Accounts the current user has muted, in server order. + private(set) var muted: [ModeratedUser] = [] + + /// True while a load round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed load / unblock / unmute. + /// Cleared on the next successful round-trip. + private(set) var error: Error? + + /// True once the first load has resolved (success or failure). Lets + /// the view distinguish first-render shimmer from a genuinely empty + /// roster. + private(set) var hasLoadedOnce: Bool = false + + /// Per-username debounce set so a rapid double-tap on an unblock / + /// unmute row does not double-fire the service call. + private var pendingOperations: Set = [] + + // MARK: - Init + + init(service: ModerationServicing) { + self.service = service + } + + // MARK: - Intents + + /// First-time + pull-to-refresh load. Fetches both rosters + /// concurrently and replaces the rendered rows. + func load() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + async let blockedUsers = service.blockedUsers() + async let mutedUsers = service.mutedUsers() + blocked = try await blockedUsers + muted = try await mutedUsers + error = nil + hasLoadedOnce = true + } catch { + self.error = error + hasLoadedOnce = true + } + } + + /// Unblocks a user. Optimistic: remove the row locally, call the + /// service, restore the snapshot on failure. Idempotent — a username + /// with an in-flight operation is a no-op. + func unblock(username: String) async { + guard !pendingOperations.contains(username) else { return } + guard let index = blocked.firstIndex(where: { $0.username == username }) else { return } + pendingOperations.insert(username) + defer { pendingOperations.remove(username) } + let snapshot = blocked + blocked.remove(at: index) + do { + try await service.unblock(username: username) + error = nil + } catch { + blocked = snapshot + self.error = error + } + } + + /// Unmutes a user. Same optimistic-removal shape as `unblock`. + func unmute(username: String) async { + guard !pendingOperations.contains(username) else { return } + guard let index = muted.firstIndex(where: { $0.username == username }) else { return } + pendingOperations.insert(username) + defer { pendingOperations.remove(username) } + let snapshot = muted + muted.remove(at: index) + do { + try await service.unmute(username: username) + error = nil + } catch { + muted = snapshot + self.error = error + } + } + + /// Convenience for tests + previews — seed the rendered rosters + /// without going through the service. + func seedForTest(blocked: [ModeratedUser], muted: [ModeratedUser]) { + self.blocked = blocked + self.muted = muted + self.hasLoadedOnce = true + } +} diff --git a/App/Features/Moderation/ModerationActionViewModel.swift b/App/Features/Moderation/ModerationActionViewModel.swift new file mode 100644 index 0000000..53a6973 --- /dev/null +++ b/App/Features/Moderation/ModerationActionViewModel.swift @@ -0,0 +1,158 @@ +// ModerationActionViewModel +// +// Backs the reusable `ModerationMenu` + `ReportReasonSheet` (the-gaps.md +// G2). One instance drives the moderation affordances for a single +// subject — a user (by username) and optionally a specific message (by +// id). Reads through `ModerationServicing` only so unit tests substitute +// a stub. +// +// Responsibilities: +// - block / mute a user (fire-and-forget; success flips `isBlocked` / +// `isMuted` so the menu can reflect the new state). +// - submit a user or message report with a `ReportReason` + optional +// free-text detail. `reportMessage` requires a message id; calling it +// without one is rejected before the service is touched. +// - surface a single `error` for the view to present. +// +// The report action validates its subject before any service call: a +// message report with no `messageID` never reaches the network. This is +// the "invalid input rejected before the service is called" gate the +// test quartet asserts on. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ModerationActionViewModel { + + // MARK: - Subject + + /// The username the moderation actions target. Block / mute / report- + /// user all key off this. + let username: String + + /// The message id when the subject is a specific message (timeline + /// row overflow menu). `nil` when the subject is a profile — in which + /// case `reportMessage` is unavailable and rejected. + let messageID: String? + + // MARK: - Dependencies + + private let service: ModerationServicing + + // MARK: - Observable state + + /// Reflects the last known block state so the menu can show + /// "Unblock" instead of "Block". Optimistically updated on a + /// successful `block()` / `unblock()`. + private(set) var isBlocked: Bool = false + + /// Reflects the last known mute state, updated the same way. + private(set) var isMuted: Bool = false + + /// True while any action round-trip is in flight. + private(set) var isBusy: Bool = false + + /// Surfaced error from the most recent failed action. Cleared at the + /// start of the next action. + private(set) var error: Error? + + /// Set true after a report submits successfully so the view can show + /// a confirmation and dismiss the sheet. Reset when a new report + /// begins. + private(set) var didSubmitReport: Bool = false + + // MARK: - Init + + init(username: String, messageID: String? = nil, service: ModerationServicing) { + self.username = username + self.messageID = messageID + self.service = service + } + + /// Whether a message report is available for this subject. `false` + /// for a profile subject (no message id). + var canReportMessage: Bool { messageID != nil } + + // MARK: - Intents + + func block() async { + await run { [self] in + try await service.block(username: username) + isBlocked = true + } + } + + func unblock() async { + await run { [self] in + try await service.unblock(username: username) + isBlocked = false + } + } + + func mute() async { + await run { [self] in + try await service.mute(username: username) + isMuted = true + } + } + + func unmute() async { + await run { [self] in + try await service.unmute(username: username) + isMuted = false + } + } + + /// Reports the subject user with a reason + optional detail. + func reportUser(reason: ReportReason, detail: String?) async { + didSubmitReport = false + await run { [self] in + try await service.reportUser(username: username, reason: reason, detail: detail) + didSubmitReport = true + } + } + + /// Reports the subject message. Rejected before any service call when + /// there is no `messageID` — a profile subject cannot report a + /// message. The rejection surfaces `ModerationActionError.noMessageSubject` + /// and leaves the service untouched. + func reportMessage(reason: ReportReason, detail: String?) async { + didSubmitReport = false + guard let messageID else { + error = ModerationActionError.noMessageSubject + return + } + await run { [self] in + try await service.reportMessage(id: messageID, reason: reason, detail: detail) + didSubmitReport = true + } + } + + // MARK: - Internals + + /// Runs one action with uniform busy / error bookkeeping. + private func run(_ body: () async throws -> Void) async { + guard !isBusy else { return } + isBusy = true + error = nil + defer { isBusy = false } + do { + try await body() + } catch { + self.error = error + } + } +} + +// MARK: - ModerationActionError + +/// Local validation errors raised before any service call. +enum ModerationActionError: Error, Equatable { + /// `reportMessage` was invoked on a subject that has no message id. + case noMessageSubject +} diff --git a/App/Features/Moderation/ModerationMenu.swift b/App/Features/Moderation/ModerationMenu.swift new file mode 100644 index 0000000..103bbc8 --- /dev/null +++ b/App/Features/Moderation/ModerationMenu.swift @@ -0,0 +1,206 @@ +// ModerationMenu + ReportReasonSheet +// +// Reusable moderation affordances (the-gaps.md G2). `ModerationMenu` +// renders the Block / Mute / Report buttons intended to sit inside an +// existing `contextMenu { … }` or overflow `Menu { … }`, so hosts can +// drop it into a profile header or a timeline row additively without +// disrupting their own actions. It owns a `ModerationActionViewModel` +// and drives block / mute directly; Report opens the `ReportReasonSheet`. +// +// Because a `contextMenu` closure cannot host a `.sheet` modifier +// reliably, the sheet presentation lives on a thin `ModerationMenuHost` +// wrapper the caller attaches to the row/header content. The typical +// usage is: +// +// rowContent +// .modifier(ModerationMenuHost(username: …, messageID: …)) +// +// which installs both the context-menu items and the report sheet. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +// MARK: - ModerationMenuHost + +/// View modifier that attaches the moderation context-menu items **and** +/// the report sheet to any content. Additive: it augments the host's own +/// context menu rather than replacing it (SwiftUI merges nested +/// `contextMenu` content when applied in sequence, and hosts that already +/// own a menu can instead embed `ModerationMenu` directly — see below). +struct ModerationMenuHost: ViewModifier { + + let username: String + var messageID: String? = nil + + @Environment(\.appEnvironment) private var environment + + @State private var actionVM: ModerationActionViewModel? + @State private var showReportSheet = false + + func body(content: Content) -> some View { + content + .contextMenu { + if let actionVM { + ModerationMenu(viewModel: actionVM, showReportSheet: $showReportSheet) + } + } + .task { + if actionVM == nil, let environment { + actionVM = ModerationActionViewModel( + username: username, + messageID: messageID, + service: environment.moderation + ) + } + } + .sheet(isPresented: $showReportSheet) { + if let actionVM { + ReportReasonSheet(viewModel: actionVM) + } + } + } +} + +extension View { + /// Attaches moderation affordances (Block / Mute / Report) to a row or + /// header. Pass a `messageID` when the subject is a specific message so + /// the "Report message" action is available. + func moderationMenu(username: String, messageID: String? = nil) -> some View { + modifier(ModerationMenuHost(username: username, messageID: messageID)) + } +} + +// MARK: - ModerationMenu + +/// The Block / Mute / Report buttons themselves, for hosts that already +/// own a `Menu` / `contextMenu` and want to compose the moderation items +/// into it. Needs a `showReportSheet` binding wired to a `.sheet` the +/// host presents (see `ModerationMenuHost`). +struct ModerationMenu: View { + + @Bindable var viewModel: ModerationActionViewModel + @Binding var showReportSheet: Bool + + var body: some View { + Group { + if viewModel.isBlocked { + Button { + Task { await viewModel.unblock() } + } label: { + Label("Unblock @\(viewModel.username)", systemImage: "hand.raised.slash") + } + } else { + Button { + Task { await viewModel.block() } + } label: { + Label("Block @\(viewModel.username)", systemImage: "hand.raised") + } + } + + if viewModel.isMuted { + Button { + Task { await viewModel.unmute() } + } label: { + Label("Unmute @\(viewModel.username)", systemImage: "speaker.wave.2") + } + } else { + Button { + Task { await viewModel.mute() } + } label: { + Label("Mute @\(viewModel.username)", systemImage: "speaker.slash") + } + } + + Divider() + + Button(role: .destructive) { + showReportSheet = true + } label: { + Label("Report\u{2026}", systemImage: "flag") + } + } + } +} + +// MARK: - ReportReasonSheet + +/// Modal report form: a reason picker over `ReportReason.allCases` plus an +/// optional free-text detail field. Submits a user report or a message +/// report depending on whether the subject has a `messageID`. +struct ReportReasonSheet: View { + + @Bindable var viewModel: ModerationActionViewModel + @Environment(\.dismiss) private var dismiss + + @State private var reason: ReportReason = .harassment + @State private var detail: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(headline) + .font(.ilTitle()) + + Picker("Reason", selection: $reason) { + ForEach(ReportReason.allCases) { reason in + Text(reason.label).tag(reason) + } + } + .pickerStyle(.menu) + .accessibilityLabel("Report reason") + + VStack(alignment: .leading, spacing: 4) { + Text("Details (optional)") + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + TextEditor(text: $detail) + .frame(minHeight: 80) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .accessibilityLabel("Report details") + } + + if let error = viewModel.error { + Text(error.localizedDescription) + .font(.ilSubtitle()) + .foregroundStyle(.red) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Submit report") { + Task { + await submit() + if viewModel.didSubmitReport { dismiss() } + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(viewModel.isBusy) + } + } + .padding(20) + .frame(width: 420) + } + + private var headline: String { + viewModel.canReportMessage + ? "Report message" + : "Report @\(viewModel.username)" + } + + private func submit() async { + let trimmedDetail = detail.trimmingCharacters(in: .whitespacesAndNewlines) + let detailOrNil = trimmedDetail.isEmpty ? nil : trimmedDetail + if viewModel.canReportMessage { + await viewModel.reportMessage(reason: reason, detail: detailOrNil) + } else { + await viewModel.reportUser(reason: reason, detail: detailOrNil) + } + } +} diff --git a/App/Features/Moderation/NoopModerationService.swift b/App/Features/Moderation/NoopModerationService.swift new file mode 100644 index 0000000..5532038 --- /dev/null +++ b/App/Features/Moderation/NoopModerationService.swift @@ -0,0 +1,26 @@ +// NoopModerationService +// +// Defensive `ModerationServicing` fallback used only when a view needs to +// construct a `ModerationActionViewModel` but the `AppEnvironment` is not +// injected (a programmer error, not a runtime one). Every call is a +// no-op so the UI degrades to inert affordances rather than crashing. +// +// Production surfaces always pass `environment.moderation`; this exists +// so a view can avoid force-unwrapping an optional environment. +// +// Per decision 0003, this file consumes only `InterlinedDomain`. + +import Foundation +import InterlinedDomain + +struct NoopModerationService: ModerationServicing { + func blockedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] { [] } + func mutedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] { [] } + func block(username: String) async throws {} + func unblock(username: String) async throws {} + func mute(username: String) async throws {} + func unmute(username: String) async throws {} + func reportUser(username: String, reason: ReportReason, detail: String?) async throws {} + func reportMessage(id: String, reason: ReportReason, detail: String?) async throws {} + func isBlocking(username: String) async throws -> Bool { false } +} diff --git a/App/Features/Search/SearchRootView.swift b/App/Features/Search/SearchRootView.swift new file mode 100644 index 0000000..81f9828 --- /dev/null +++ b/App/Features/Search/SearchRootView.swift @@ -0,0 +1,330 @@ +// SearchRootView +// +// Global search surface (the-gaps.md G5). A single search field over the +// current user's messages, lists, and documents, with the hits grouped +// under three sections. The view is a thin shell over `SearchViewModel`: +// it binds the field, dispatches submit / clear, and renders whichever +// state the view model is in. +// +// Row rendering reuses the existing App-layer row components where they +// exist — `MessageRowView` for message hits and `ListRowSummaryView` for +// list hits — so search results look identical to their home surfaces. A +// small inline `DocumentSearchRow` covers documents (the Documents +// feature's own row is file-private). +// +// Deep-linking: tapping a message hit routes the sidebar to Timeline and +// asks it to open that message (the same `.notificationDeepLink` channel +// the system-banner router uses); tapping a list hit routes to Lists. +// Documents open the editor is deferred — tapping a document hit routes +// to the Documents section so the user lands on the right surface. +// +// Per decision 0003, this view consumes only `InterlinedDomain`. + +import SwiftUI +import InterlinedDomain + +struct SearchRootView: View { + + @Environment(\.appEnvironment) private var environment + + @State private var viewModel: SearchViewModel? + + /// Focus the field on appear and when the ⌘F menu command fires so the + /// user can start typing immediately. + @FocusState private var searchFieldFocused: Bool + + var body: some View { + NavigationStack { + Group { + if let viewModel { + searchBody(viewModel: viewModel) + } else { + unconfiguredState + } + } + .navigationTitle("Search") + } + .task { + if viewModel == nil, let environment { + viewModel = SearchViewModel(service: environment.search) + } + searchFieldFocused = true + } + // ⌘F focuses the field. `SearchMenuCommands` posts this; the + // sidebar has already switched to `.search` by the time it fires. + .onReceive(NotificationCenter.default.publisher(for: .searchFocus)) { _ in + searchFieldFocused = true + } + } + + // MARK: - Body sections + + @ViewBuilder + private func searchBody(viewModel: SearchViewModel) -> some View { + VStack(spacing: 0) { + searchField(viewModel: viewModel) + Divider() + content(viewModel: viewModel) + } + } + + @ViewBuilder + private func searchField(viewModel: SearchViewModel) -> some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + TextField( + "Search messages, lists, and documents", + text: Binding( + get: { viewModel.query }, + set: { viewModel.query = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .focused($searchFieldFocused) + .onSubmit { + Task { await viewModel.searchNow() } + } + .accessibilityLabel("Search query") + + if viewModel.isSearching { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Searching") + } else if !viewModel.query.isEmpty { + Button { + viewModel.clear() + searchFieldFocused = true + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + @ViewBuilder + private func content(viewModel: SearchViewModel) -> some View { + if let error = viewModel.error { + errorState(error: error, viewModel: viewModel) + } else if !viewModel.hasSearched { + promptState + } else if viewModel.results.isEmpty { + emptyState(query: viewModel.query) + } else { + resultsList(viewModel: viewModel) + } + } + + @ViewBuilder + private func resultsList(viewModel: SearchViewModel) -> some View { + let results = viewModel.results + List { + if !results.messages.isEmpty { + Section("Messages") { + ForEach(results.messages) { message in + Button { + openMessage(id: message.id) + } label: { + MessageRowView(message: message) + } + .buttonStyle(.plain) + .accessibilityHint("Opens in the timeline") + } + } + } + if !results.lists.isEmpty { + Section("Lists") { + ForEach(results.lists) { summary in + Button { + openLists() + } label: { + ListRowSummaryView(summary: summary) + } + .buttonStyle(.plain) + .accessibilityHint("Opens in the Lists section") + } + } + } + if !results.documents.isEmpty { + Section("Documents") { + ForEach(results.documents) { document in + Button { + openDocuments() + } label: { + DocumentSearchRow(document: document) + } + .buttonStyle(.plain) + .accessibilityHint("Opens in the Documents section") + } + } + } + } + .listStyle(.inset) + } + + // MARK: - Deep-link routing + + /// Route the sidebar to Timeline and open the tapped message. Reuses + /// the `.notificationDeepLink` channel `MainWindowView` already + /// observes for system-banner taps, so no new routing wiring is + /// needed in the main window. + private func openMessage(id: String) { + NotificationCenter.default.post( + name: .notificationDeepLink, + object: NotificationTarget.message(id: id) + ) + } + + private func openLists() { + NotificationCenter.default.post( + name: .notificationDeepLink, + object: NotificationTarget.list(id: "") + ) + } + + /// No `.documents` case exists on the deep-link router, so route via + /// the dedicated documents-show channel the menu command uses. + private func openDocuments() { + NotificationCenter.default.post(name: .searchShowDocuments, object: nil) + } + + // MARK: - States + + private var promptState: some View { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.ilDisplay(36)) + .foregroundStyle(Color.accentColor) + Text("Search InterlinedList") + .font(.ilSubtitle()) + Text("Find messages, lists, and documents across your account.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func emptyState(query: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.ilDisplay(36)) + .foregroundStyle(.secondary) + Text("No results") + .font(.ilSubtitle()) + Text("Nothing matched \u{201C}\(query.trimmingCharacters(in: .whitespacesAndNewlines))\u{201D}.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func errorState(error: Error, viewModel: SearchViewModel) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.ilDisplay(36)) + .foregroundStyle(Color.accentColor) + Text("Search failed") + .font(.ilSubtitle()) + Text(error.localizedDescription) + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Button("Try again") { + Task { await viewModel.searchNow() } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var unconfiguredState: some View { + VStack(spacing: 8) { + Image(systemName: "wrench.adjustable") + .font(.ilDisplay(36)) + .foregroundStyle(.secondary) + Text("Search unavailable") + .font(.ilSubtitle()) + Text("AppEnvironment is not injected into the view tree.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - DocumentSearchRow + +/// Compact document hit row — title, an excerpt of the Markdown body, and +/// a relative "updated" stamp. Kept local to the Search feature because +/// the Documents feature's own row component is file-private. +private struct DocumentSearchRow: View { + let document: Document + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "doc.text") + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + Text(document.title.isEmpty ? "Untitled document" : document.title) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: 8) + } + if !excerpt.isEmpty { + Text(excerpt) + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + Label( + Self.relativeFormatter.localizedString(for: document.updatedAt, relativeTo: .now), + systemImage: "clock" + ) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .accessibilityLabel("Updated \(Self.fullFormatter.string(from: document.updatedAt))") + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilitySummary) + } + + /// First non-empty line of the Markdown body, trimmed. + private var excerpt: String { + document.body.markdown + .split(separator: "\n", omittingEmptySubsequences: true) + .first + .map { String($0).trimmingCharacters(in: .whitespaces) } ?? "" + } + + private var accessibilitySummary: String { + var parts: [String] = [document.title.isEmpty ? "Untitled document" : document.title] + if !excerpt.isEmpty { parts.append(excerpt) } + parts.append("Updated \(Self.fullFormatter.string(from: document.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/Search/SearchViewModel.swift b/App/Features/Search/SearchViewModel.swift new file mode 100644 index 0000000..f2a1dbd --- /dev/null +++ b/App/Features/Search/SearchViewModel.swift @@ -0,0 +1,165 @@ +// SearchViewModel +// +// Drives `SearchRootView`: the global search surface (the-gaps.md G5). +// Owns the debounced query string, the grouped `SearchResults`, and the +// loading / error state. Reads through `SearchServicing` only — no direct +// API access — so unit tests substitute a stub service. +// +// Debounce: every keystroke re-binds `query`; `search()` cancels the +// in-flight fetch and schedules a fresh one `debounce` after the most +// recent keystroke (mirrors `DocumentEditorViewModel`'s injectable +// `ContinuousClock`-driven debounce so tests run with `.zero`). A blank / +// whitespace-only query clears the results synchronously and never fans +// out a request — the domain `SearchServicing.all(query:)` short-circuits +// too, but clearing locally keeps the UI honest without a round-trip. +// +// A monotonically-increasing `generation` token guards against a slow +// early request landing after a faster later one — a stale response is +// dropped rather than clobbering fresher results. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class SearchViewModel { + + // MARK: - Configuration + + /// Debounce window applied to the query field. Matches the keystroke + /// cadence of the composer / editor so the global search feels the + /// same. Tests pass `.zero` for immediate resolution. + static let searchDebounce: Duration = .milliseconds(300) + + /// Page size handed to the search service for each resource. + static let pageSize: Int = 20 + + // MARK: - Dependencies + + private let service: SearchServicing + private let debounce: Duration + + // MARK: - Observable state + + /// The bound query string. Setting it (re)schedules a debounced + /// search; the view binds a `TextField` to it directly. + var query: String = "" { + didSet { + guard query != oldValue else { return } + scheduleSearch() + } + } + + /// The grouped hits from the most recent successful search. Cleared + /// when the query is blanked. + private(set) var results: SearchResults = .empty + + /// True while a search round-trip is in flight. + private(set) var isSearching: Bool = false + + /// Surfaced error from the most recent failed search. Cleared on the + /// next successful round-trip or when the query is blanked. + private(set) var error: Error? + + /// True once a search for a non-blank query has resolved (success or + /// failure). Lets the view distinguish "type something" from "no + /// results for this term". + private(set) var hasSearched: Bool = false + + // MARK: - Internals + + /// Owns the debounced search task so a fresh keystroke cancels the + /// pending fetch. `[weak self]` capture; no `deinit`-time cancel + /// needed (Observation-macro semantics — mirrors `CurrentUserStore`). + private var pendingSearchTask: Task? + + /// Monotonic token: only the newest issued search may write results. + private var generation: Int = 0 + + // MARK: - Init + + init(service: SearchServicing, debounce: Duration = searchDebounce) { + self.service = service + self.debounce = debounce + } + + // MARK: - Intents + + /// Runs the current query immediately, bypassing the debounce. Bound + /// to the search field's `onSubmit` so pressing return doesn't wait + /// out the debounce window. + func searchNow() async { + pendingSearchTask?.cancel() + await performSearch() + } + + /// Clears the query, results, and error in one shot. Bound to the + /// field's clear button. + func clear() { + pendingSearchTask?.cancel() + query = "" + results = .empty + error = nil + hasSearched = false + isSearching = false + } + + // MARK: - Debounce + + private func scheduleSearch() { + pendingSearchTask?.cancel() + // Blank query: clear synchronously, no request. `SearchServicing` + // short-circuits a blank term too, but clearing locally keeps the + // rendered state consistent the instant the field empties. + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = .empty + error = nil + hasSearched = false + isSearching = false + return + } + let interval = debounce + pendingSearchTask = Task { [weak self] in + if interval > .zero { + try? await Task.sleep(for: interval) + } + guard let self, !Task.isCancelled else { return } + await self.performSearch() + } + } + + private func performSearch() async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + // A blank term at fire time (e.g. the user deleted everything + // during the debounce) clears rather than searching. + guard !trimmed.isEmpty else { + results = .empty + error = nil + hasSearched = false + isSearching = false + return + } + generation += 1 + let issued = generation + isSearching = true + do { + let hits = try await service.all(query: trimmed, limit: Self.pageSize) + // Drop a stale response — a newer search superseded this one. + guard issued == generation else { return } + results = hits + error = nil + hasSearched = true + isSearching = false + } catch { + guard issued == generation else { return } + self.error = error + results = .empty + hasSearched = true + isSearching = false + } + } +} diff --git a/App/Features/Settings/SettingsRootView.swift b/App/Features/Settings/SettingsRootView.swift index 19c62b6..813e692 100644 --- a/App/Features/Settings/SettingsRootView.swift +++ b/App/Features/Settings/SettingsRootView.swift @@ -25,6 +25,13 @@ struct SettingsRootView: View { .tabItem { Label("Account", systemImage: "person.crop.circle") } + + // Web-parity (the-gaps.md G2) — blocked / muted account + // management with inline unblock / unmute. + BlockedAndMutedView() + .tabItem { + Label("Blocked & Muted", systemImage: "hand.raised") + } } .frame(width: 560, height: 500) } diff --git a/App/Features/Sharing/ResolveShareView.swift b/App/Features/Sharing/ResolveShareView.swift new file mode 100644 index 0000000..878d42b --- /dev/null +++ b/App/Features/Sharing/ResolveShareView.swift @@ -0,0 +1,193 @@ +// ResolveShareView +// +// The shared-resource landing (the-gaps.md G3). Presented as a sheet when +// the user opens a `…/lists/shared/{token}` or `…/documents/shared/{token}` +// link — pasted into the landing field or delivered via the +// `interlinedlist://` deep-link scheme. Shows the resolved resource title + +// granted role and, when claimable and signed in, a "Claim access" button. +// When the share needs auth (or no user is resolved), it prompts sign-in +// rather than rendering a broken claim button (ownership-gating). +// +// The current-user id is read from `AppEnvironment.currentUserStore` and +// handed to the view model as a plain `String?`, so the view model stays +// session-graph-free and unit-testable. + +import SwiftUI +import InterlinedDomain + +struct ResolveShareView: View { + + let parsed: ParsedShare + let environment: AppEnvironment + /// Called with the claim's resource id once the user successfully claims + /// access, so the host can route to the resource (or just dismiss). + var onClaimed: (ShareClaim) -> Void = { _ in } + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: ResolveShareViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel: viewModel) + } else { + ProgressView() + .accessibilityLabel("Opening shared link") + .padding() + } + } + .frame(minWidth: 420, minHeight: 300) + .task { + if viewModel == nil { + let model = ResolveShareViewModel( + service: environment.sharing, + parsed: parsed, + currentUserID: environment.currentUserStore.currentUserID + ) + viewModel = model + await model.resolve() + } + } + } + + @ViewBuilder + private func content(viewModel: ResolveShareViewModel) -> some View { + VStack(alignment: .leading, spacing: 16) { + header + + if viewModel.isLoading && viewModel.resolved == nil { + ProgressView("Resolving link…") + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } else if let error = viewModel.error, viewModel.resolved == nil { + errorState(error: error, viewModel: viewModel) + } else if viewModel.didClaim { + claimedState(viewModel: viewModel) + } else if let resolved = viewModel.resolved { + resolvedState(resolved: resolved, viewModel: viewModel) + } + + Spacer() + footer(viewModel: viewModel) + } + .padding(16) + } + + // MARK: - States + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Shared \(parsed.kind == .list ? "List" : "Document")") + .font(.ilTitle(20)) + Text("Someone shared this \(parsed.kind == .list ? "list" : "document") with you.") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func resolvedState(resolved: ResolvedShare, viewModel: ResolveShareViewModel) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: parsed.kind == .list ? "list.bullet.rectangle" : "doc.text") + .font(.ilDisplay(28)) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(resolved.resource?.title ?? "Shared resource") + .font(.ilSubtitle()) + Text("Grants \(resolved.role.label) access") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + } + + if viewModel.needsSignIn { + signInPrompt + } + } + } + + private var signInPrompt: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "person.crop.circle.badge.exclamationmark") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text("Sign in to claim access to this \(parsed.kind == .list ? "list" : "document").") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + } + + private func claimedState(viewModel: ResolveShareViewModel) -> some View { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.ilDisplay(28)) + .foregroundStyle(.green) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text("Access granted") + .font(.ilSubtitle()) + if let role = viewModel.claim?.role { + Text("You now have \(role.label) access.") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + } + } + } + + private func errorState(error: Error, viewModel: ResolveShareViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + Label("Couldn't open this link", systemImage: "exclamationmark.triangle") + .font(.ilBody().weight(.semibold)) + Text(error.localizedDescription) + .font(.ilMono(11)) + .foregroundStyle(.secondary) + Button("Try Again") { + Task { await viewModel.resolve() } + } + .buttonStyle(.bordered) + } + } + + // MARK: - Footer + + @ViewBuilder + private func footer(viewModel: ResolveShareViewModel) -> some View { + HStack { + Button("Close") { dismiss() } + .buttonStyle(.bordered) + Spacer() + if viewModel.didClaim { + Button("Open") { + if let claim = viewModel.claim { onClaimed(claim) } + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } else if viewModel.canOfferClaim { + Button { + Task { + await viewModel.claimAccess() + if let claim = viewModel.claim, viewModel.didClaim { + onClaimed(claim) + } + } + } label: { + if viewModel.isLoading { + ProgressView().controlSize(.small) + } else { + Text("Claim Access") + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(viewModel.isLoading) + } + } + } +} diff --git a/App/Features/Sharing/ResolveShareViewModel.swift b/App/Features/Sharing/ResolveShareViewModel.swift new file mode 100644 index 0000000..acaee15 --- /dev/null +++ b/App/Features/Sharing/ResolveShareViewModel.swift @@ -0,0 +1,145 @@ +// ResolveShareViewModel +// +// Drives `ResolveShareView` — the shared-resource landing shown when the +// user opens a `…/lists/shared/{token}` or `…/documents/shared/{token}` +// link, whether pasted or delivered via the `interlinedlist://` deep-link +// scheme (the-gaps.md G3). +// +// It resolves the token (`resolveListShare` / `resolveDocumentShare`), +// surfaces the resource title + granted role, and — when the resolved +// share `canClaim` and the user is signed in — offers a "Claim access" +// button that calls the matching claim method. When the resolved share +// `needsAuth` (or no current user is known), the view prompts sign-in +// instead of showing the claim button (ownership-gating: never render an +// enabled-but-broken action). +// +// Reads through `SharingServicing` only; the current-user id is injected +// as a plain `String?` so tests don't need a session graph. A `nil` +// current-user id is the "signed out" signal (mirrors the project's +// ownership-gating convention). +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ResolveShareViewModel { + + // MARK: - Dependencies + + private let service: SharingServicing + let parsed: ParsedShare + /// The signed-in user's id, or `nil` when the session is unresolved / + /// signed out. Drives the sign-in-vs-claim decision. + private(set) var currentUserID: String? + + // MARK: - Observable state + + /// The resolved share once `resolve()` succeeds. `nil` before the first + /// resolve or after a resolve failure. + private(set) var resolved: ResolvedShare? + + /// True while a resolve / claim round-trip is in flight. + private(set) var isLoading: Bool = false + + /// Surfaced error from the most recent failed resolve / claim. + private(set) var error: Error? + + /// True once a claim has succeeded — the view swaps to a "You now have + /// access" confirmation and can offer to open the resource. + private(set) var didClaim: Bool = false + + /// The claim's authoritative result (resource id + granted role) once + /// `claim()` succeeds. Lets the caller navigate to the resource. + private(set) var claim: ShareClaim? + + // MARK: - Init + + init(service: SharingServicing, parsed: ParsedShare, currentUserID: String?) { + self.service = service + self.parsed = parsed + self.currentUserID = currentUserID + } + + // MARK: - Derived UI state + + /// Whether the resolved share is claimable *and* the user is signed in. + /// The claim button is shown only when this is true; otherwise the view + /// shows the sign-in prompt (when auth is needed) or a plain preview. + var canOfferClaim: Bool { + guard let resolved else { return false } + return resolved.canClaim && currentUserID != nil + } + + /// Whether the view should prompt sign-in: the share needs auth, or the + /// share is claimable but no current user is resolved yet. Ownership- + /// gating: a claimable link with an unknown user prompts sign-in rather + /// than rendering a broken claim button. + var needsSignIn: Bool { + guard let resolved else { return false } + if resolved.needsAuth { return true } + return resolved.canClaim && currentUserID == nil + } + + // MARK: - Intents + + /// Resolves the token, populating `resolved` (title / role / claimable). + func resolve() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + resolved = try await resolveShare() + error = nil + } catch { + resolved = nil + self.error = error + } + } + + /// Claims access to the resource. Guarded: does nothing (surfaces no + /// error) when the current share is not claimable or the user is signed + /// out — the view never presents the button in that state, but the + /// guard keeps a programmatic call safe. On success sets `didClaim` and + /// records the authoritative `ShareClaim`. + func claimAccess() async { + guard canOfferClaim else { return } + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let result = try await claimShare() + claim = result + didClaim = true + error = nil + } catch { + self.error = error + } + } + + /// Updates the known current-user id (e.g. after an in-flow sign-in + /// resolves). Lets the view re-evaluate `canOfferClaim` / `needsSignIn` + /// without rebuilding the view model. + func updateCurrentUser(id: String?) { + currentUserID = id + } + + // MARK: - Target dispatch + + private func resolveShare() async throws -> ResolvedShare { + switch parsed.kind { + case .list: return try await service.resolveListShare(token: parsed.token) + case .document: return try await service.resolveDocumentShare(token: parsed.token) + } + } + + private func claimShare() async throws -> ShareClaim { + switch parsed.kind { + case .list: return try await service.claimListShare(token: parsed.token) + case .document: return try await service.claimDocumentShare(token: parsed.token) + } + } +} diff --git a/App/Features/Sharing/ShareLinkDeepLink.swift b/App/Features/Sharing/ShareLinkDeepLink.swift new file mode 100644 index 0000000..8b94e3a --- /dev/null +++ b/App/Features/Sharing/ShareLinkDeepLink.swift @@ -0,0 +1,46 @@ +// ShareLinkDeepLink +// +// App-layer glue that turns an opened share URL (pasted or delivered via +// the `interlinedlist://` deep-link scheme) into a routed presentation of +// `ResolveShareView` (the-gaps.md G3). Mirrors the project's notification- +// name convention: a `Notification.Name` colocated with the feature and a +// static poster so the URL handler in `InterlinedListApp` stays a +// one-liner and `MainWindowView` owns the sheet presentation. +// +// The URL handler in `AppRootView` (`InterlinedListApp.swift`) already +// filters for the OAuth callback; this adds share-link handling *alongside* +// it: an `interlinedlist://…/shared/{token}` (or an https share URL) is +// parsed by `ShareURLParser`, and on a hit we post `.openShareLink` with +// the `ParsedShare` as the object. `MainWindowView` observes it and +// presents the landing sheet. A non-share URL returns `false` so the +// existing OAuth handling is untouched. + +import Foundation + +extension Foundation.Notification.Name { + /// Posted when an opened URL resolves to a share link. `object` is the + /// `ParsedShare`. Observed by `MainWindowView`, which presents + /// `ResolveShareView`. + static let openShareLink = Foundation.Notification.Name("InterlinedList.openShareLink") +} + +enum ShareLinkDeepLink { + + /// Attempts to route `url` as a share link. Returns `true` (and posts + /// `.openShareLink`) when the URL is a recognized share link; returns + /// `false` otherwise so the caller can fall through to other handlers + /// (e.g. the OAuth callback). `post` defaults to `nil`, in which case + /// the parsed share is posted to `NotificationCenter.default`; tests + /// pass a capturing closure to observe the routing without the center. + @discardableResult + @MainActor + static func handle(_ url: URL, post: ((ParsedShare) -> Void)? = nil) -> Bool { + guard let parsed = ShareURLParser.parse(url) else { return false } + if let post { + post(parsed) + } else { + NotificationCenter.default.post(name: .openShareLink, object: parsed) + } + return true + } +} diff --git a/App/Features/Sharing/ShareLinksView.swift b/App/Features/Sharing/ShareLinksView.swift new file mode 100644 index 0000000..538ba09 --- /dev/null +++ b/App/Features/Sharing/ShareLinksView.swift @@ -0,0 +1,269 @@ +// ShareLinksView +// +// The "Links" panel of the share sheet for a list or a document +// (the-gaps.md G3). A create form (role picker over `ShareRole.allCases` +// + optional expiry) atop a list of active links, each with a +// copy/share affordance and a revoke button. Presented as a sheet from +// the Lists toolbar and the Documents editor toolbar. +// +// Clipboard without AppKit: each active link row uses SwiftUI's built-in +// `ShareLink` view (the system share sheet, which includes "Copy") to +// hand the URL to the user. No `NSPasteboard`, no `import AppKit` +// (Decision 0004 SwiftUI-only). Because our domain model is *also* named +// `ShareLink`, this file refers to it as `InterlinedDomain.ShareLink` +// wherever the two could collide. +// +// Subscriber gate: when the view model raises `showSubscriberUpsell` +// (create blocked for a free account), the form area swaps to an upsell +// callout instead of surfacing an error banner. + +import SwiftUI +import InterlinedDomain + +struct ShareLinksView: View { + + let target: ShareTarget + let environment: AppEnvironment + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: ShareLinksViewModel? + @State private var expiryEnabled: Bool = false + + var body: some View { + Group { + if let viewModel { + content(viewModel: viewModel) + } else { + ProgressView() + .accessibilityLabel("Loading share links") + .padding() + } + } + .frame(minWidth: 520, minHeight: 460) + .task { + if viewModel == nil { + let model = ShareLinksViewModel(service: environment.sharing, target: target) + viewModel = model + await model.load() + } + } + } + + @ViewBuilder + private func content(viewModel: ShareLinksViewModel) -> some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + createForm(viewModel: viewModel) + Divider() + activeLinks(viewModel: viewModel) + if let error = viewModel.error { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + Divider() + footer + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Share Links") + .font(.ilTitle(20)) + Text("Create a link anyone can use to open this \(target.noun).") + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + + // MARK: - Create form + + @ViewBuilder + private func createForm(viewModel: ShareLinksViewModel) -> some View { + if viewModel.showSubscriberUpsell { + subscriberUpsell(viewModel: viewModel) + } else { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Picker("Access", selection: Binding( + get: { viewModel.newRole }, + set: { viewModel.newRole = $0 } + )) { + ForEach(ShareRole.allCases) { role in + Text(role.label).tag(role) + } + } + .pickerStyle(.menu) + .frame(width: 160) + .accessibilityLabel("Access level for new link") + + Toggle("Expires", isOn: Binding( + get: { expiryEnabled }, + set: { on in + expiryEnabled = on + viewModel.newExpiresAt = on ? (viewModel.newExpiresAt ?? defaultExpiry) : nil + } + )) + .toggleStyle(.checkbox) + + if expiryEnabled { + DatePicker( + "Expiry date", + selection: Binding( + get: { viewModel.newExpiresAt ?? defaultExpiry }, + set: { viewModel.newExpiresAt = $0 } + ), + in: Date()..., + displayedComponents: [.date] + ) + .labelsHidden() + .datePickerStyle(.field) + } + + Spacer() + + Button { + Task { await viewModel.create() } + } label: { + if viewModel.isCreating { + ProgressView().controlSize(.small) + } else { + Label("Create Link", systemImage: "link.badge.plus") + } + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isCreating) + .accessibilityLabel("Create share link") + } + Text(roleHint(viewModel.newRole)) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + } + .padding(12) + } + } + + private func subscriberUpsell(viewModel: ShareLinksViewModel) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "star.circle.fill") + .font(.ilDisplay(24)) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text("Share links are a subscriber feature") + .font(.ilBody().weight(.semibold)) + Text("Upgrade your account to create shareable links for your lists and documents. Existing links keep working, and you can still revoke them.") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + Spacer() + Button("Dismiss") { viewModel.dismissUpsell() } + .buttonStyle(.bordered) + } + .padding(12) + .background(Color.accentColor.opacity(0.08)) + } + + // MARK: - Active links + + @ViewBuilder + private func activeLinks(viewModel: ShareLinksViewModel) -> some View { + List { + Section("Active links") { + if viewModel.links.isEmpty, viewModel.isLoading, !viewModel.hasLoadedOnce { + ProgressView() + .accessibilityLabel("Loading links") + .frame(maxWidth: .infinity) + } else if viewModel.links.isEmpty { + Text("No active links yet.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.links) { link in + linkRow(viewModel: viewModel, link: link) + } + } + } + } + } + + @ViewBuilder + private func linkRow(viewModel: ShareLinksViewModel, link: InterlinedDomain.ShareLink) -> some View { + HStack(spacing: 10) { + Image(systemName: "link") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(link.role.label) + .font(.ilBody()) + Text(subtitle(for: link)) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Spacer() + if let url = shareURL(for: link) { + // SwiftUI's system ShareLink — includes "Copy" — so no + // AppKit pasteboard is needed (Decision 0004). + SwiftUI.ShareLink(item: url) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share this link") + } + Button(role: .destructive) { + Task { await viewModel.revoke(token: link.token) } + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .accessibilityLabel("Revoke this link") + } + .padding(.vertical, 2) + } + + // MARK: - Footer + + private var footer: some View { + HStack { + Spacer() + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + + // MARK: - Presentation helpers + + /// A best-effort share URL: prefer the server-provided `url`, else build + /// the canonical web URL from the default base + token. + private func shareURL(for link: InterlinedDomain.ShareLink) -> URL? { + if let url = link.url { return url } + let kind: ParsedShare.Kind = target.isDocument ? .document : .list + return ShareURLParser.webURL(base: environment.shareBaseURL, kind: kind, token: link.token) + } + + private func subtitle(for link: InterlinedDomain.ShareLink) -> String { + if let url = shareURL(for: link) { return url.absoluteString } + return "Token: \(link.token)" + } + + private func roleHint(_ role: ShareRole) -> String { + switch role { + case .watcher: return "Viewers can open and read, but not change anything." + case .collaborator: return "Editors can open and change content." + case .manager: return "Admins can open, change content, and manage settings." + } + } + + private var defaultExpiry: Date { + Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date().addingTimeInterval(7 * 86_400) + } +} diff --git a/App/Features/Sharing/ShareLinksViewModel.swift b/App/Features/Sharing/ShareLinksViewModel.swift new file mode 100644 index 0000000..33973fe --- /dev/null +++ b/App/Features/Sharing/ShareLinksViewModel.swift @@ -0,0 +1,206 @@ +// ShareLinksViewModel +// +// Drives `ShareLinksView` — the "Links" tab of the share panel for a list +// or a document (the-gaps.md G3). Owns the loaded active links, the create +// form state (role + optional expiry), the loading / error state, and the +// create / copy / revoke intents. Reads through `SharingServicing` only — +// no direct API or entitlements access — so unit tests substitute a stub +// service. +// +// Target dispatch: a `ShareTarget` selects the list vs. document half of +// the service. Every intent forks on `target` once and calls the matching +// pair, so the view layer stays target-agnostic. +// +// Subscriber gate: link *creation* is subscriber-only. The domain service +// throws `SharingError.subscriberRequired` before any HTTP when the account +// is not a subscriber. This view model catches that specific case and +// raises `showSubscriberUpsell` instead of surfacing a raw error, so the +// view can present an upsell rather than an error banner (mirrors +// `ListFoldersViewModel`). Every other failure flows into `error`. +// +// Optimistic revoke: revoking prunes the link from the rendered list +// immediately, calls the service, and restores the snapshot on failure +// (the proven M2 optimistic pattern). A per-token debounce set guards +// against a rapid double-tap double-firing the revoke. +// +// Per decision 0003, this view model consumes only `InterlinedDomain`. + +import Foundation +import Observation +import InterlinedDomain + +@MainActor +@Observable +final class ShareLinksViewModel { + + // MARK: - Dependencies + + private let service: SharingServicing + let target: ShareTarget + + // MARK: - Observable state + + /// The active (non-revoked) links returned by the most recent load or + /// create. Roots the "Active links" list in the panel. + private(set) var links: [ShareLink] = [] + + /// True while the initial load / a refresh round-trip is in flight. + private(set) var isLoading: Bool = false + + /// True while a create round-trip is in flight (drives the form's + /// spinner / disabled state independently of a background refresh). + private(set) var isCreating: Bool = false + + /// Surfaced error from the most recent failed load / create / revoke. + /// The subscriber-gate case is routed to `showSubscriberUpsell` + /// instead, so this never carries a `subscriberRequired`. + private(set) var error: Error? + + /// Raised when a create was blocked because the account is not a + /// subscriber. The view presents an upsell instead of an error banner. + /// Reset by `dismissUpsell()` or the next successful create. + private(set) var showSubscriberUpsell: Bool = false + + /// True once the first load has resolved (success or failure). Lets the + /// view distinguish "loading" from "no links yet". + private(set) var hasLoadedOnce: Bool = false + + // MARK: - Create-form state (bound by the view) + + /// The role the next created link will grant. Bound to the role picker. + var newRole: ShareRole = .watcher + + /// The optional expiry for the next created link. `nil` == never + /// expires. Bound to the expiry controls (a toggle + date picker in the + /// view). + var newExpiresAt: Date? + + // MARK: - Internals + + /// Per-token debounce set so a rapid double-tap on revoke doesn't + /// double-fire the service call (proven M2 pattern). + private var pendingRevocations: Set = [] + + // MARK: - Init + + init(service: SharingServicing, target: ShareTarget) { + self.service = service + self.target = target + } + + // MARK: - Intents + + /// First-time + refresh load. Replaces the rendered active-links list + /// with the server's authoritative set. + func load() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let all = try await fetchLinks() + links = Self.activeOnly(all) + error = nil + hasLoadedOnce = true + } catch { + self.error = error + hasLoadedOnce = true + } + } + + /// Creates a link with the current form's role + expiry, then prepends + /// the server's authoritative link to the rendered list. A + /// `subscriberRequired` failure raises the upsell instead of an error; + /// any other failure flows into `error`. Returns the created link on + /// success so the caller can, e.g., immediately offer it for sharing. + @discardableResult + func create() async -> ShareLink? { + guard !isCreating else { return nil } + showSubscriberUpsell = false + isCreating = true + defer { isCreating = false } + do { + let created = try await createLink(role: newRole, expiresAt: newExpiresAt) + // Trust the server's return, not a locally-synthesized link + // (proven optimistic-return pattern). Prepend so the newest + // link is first. + links.insert(created, at: 0) + error = nil + return created + } catch SharingError.subscriberRequired { + showSubscriberUpsell = true + return nil + } catch { + self.error = error + return nil + } + } + + /// Revokes a link. Optimistic: prune it from the rendered list, call + /// the service, restore the snapshot on failure (proven M2 pattern). + /// The per-token debounce set guards a rapid double-tap. + func revoke(token: String) async { + guard !pendingRevocations.contains(token) else { return } + pendingRevocations.insert(token) + defer { pendingRevocations.remove(token) } + let snapshot = links + links.removeAll { $0.token == token } + do { + let revoked = try await revokeLink(token: token) + if revoked { + error = nil + } else { + // Server reported the token was not revoked — restore so + // the UI reflects the true state. + links = snapshot + } + } catch { + links = snapshot + self.error = error + } + } + + /// Dismisses the subscriber upsell (the "Upgrade" affordance was shown / + /// cancelled). + func dismissUpsell() { + showSubscriberUpsell = false + } + + /// Convenience for tests + previews — seed the rendered list without a + /// service round-trip. + func seedForTest(links: [ShareLink]) { + self.links = links + self.hasLoadedOnce = true + } + + // MARK: - Target dispatch + + private func fetchLinks() async throws -> [ShareLink] { + switch target { + case .list(let id): return try await service.listShareLinks(listId: id) + case .document(let id): return try await service.documentShareLinks(documentId: id) + } + } + + private func createLink(role: ShareRole, expiresAt: Date?) async throws -> ShareLink { + switch target { + case .list(let id): + return try await service.createListShareLink(listId: id, role: role, expiresAt: expiresAt) + case .document(let id): + return try await service.createDocumentShareLink(documentId: id, role: role, expiresAt: expiresAt) + } + } + + private func revokeLink(token: String) async throws -> Bool { + switch target { + case .list(let id): return try await service.revokeListShareLink(listId: id, token: token) + case .document(let id): return try await service.revokeDocumentShareLink(documentId: id, token: token) + } + } + + // MARK: - Pure helpers + + /// Keeps only links the server still resolves (not revoked). Pure. + private static func activeOnly(_ links: [ShareLink]) -> [ShareLink] { + links.filter { !$0.isRevoked } + } +} diff --git a/App/Features/Sharing/ShareTarget.swift b/App/Features/Sharing/ShareTarget.swift new file mode 100644 index 0000000..7694821 --- /dev/null +++ b/App/Features/Sharing/ShareTarget.swift @@ -0,0 +1,36 @@ +// ShareTarget +// +// The resource a share-links panel is scoped to (the-gaps.md G3). A list +// or a document — the `ShareLinksViewModel` switches on this to dispatch +// to the `list*` or `document*` half of `SharingServicing`, so the view +// layer builds one panel regardless of which resource it's sharing. +// +// Per decision 0003, this type lives in the App layer and depends on +// `InterlinedDomain` only. + +import Foundation + +/// Identifies which resource a share-links panel manages links for. +enum ShareTarget: Equatable, Hashable { + case list(id: String) + case document(id: String) + + /// The underlying resource id, used for the debounce key and for + /// building/parsing share URLs. + var id: String { + switch self { + case .list(let id): return id + case .document(let id): return id + } + } + + /// Whether this target is a document (vs. a list) — drives the copy in + /// the panel header and the URL path segment (`documents` vs `lists`). + var isDocument: Bool { + if case .document = self { return true } + return false + } + + /// Human-facing noun for the header ("list" / "document"). + var noun: String { isDocument ? "document" : "list" } +} diff --git a/App/Features/Sharing/ShareURLParser.swift b/App/Features/Sharing/ShareURLParser.swift new file mode 100644 index 0000000..882546e --- /dev/null +++ b/App/Features/Sharing/ShareURLParser.swift @@ -0,0 +1,96 @@ +// ShareURLParser +// +// Pure parser that turns a pasted share URL or an `interlinedlist://` +// deep link into a typed `ParsedShare` (the-gaps.md G3). Recognizes both +// resource shapes: +// • lists: …/lists/shared/{token} +// • documents: …/documents/shared/{token} +// across three URL forms: +// • https://interlinedlist.com/lists/shared/{token} +// • interlinedlist://lists/shared/{token} (host = "lists") +// • interlinedlist://share/lists/shared/{token} (host = "share") +// +// The App's deep-link handler and the "paste a share link" field both call +// `ShareURLParser.parse(_:)`; keeping the logic here (pure, string-only) +// means it is exhaustively unit-testable without a live URL round-trip and +// the two call sites can't drift. +// +// Per decision 0003, this type lives in the App layer and depends on +// `InterlinedDomain` only (in fact only Foundation). + +import Foundation + +/// A share reference extracted from a URL — which resource kind, and the +/// opaque token to resolve. +struct ParsedShare: Equatable { + enum Kind: Equatable { case list, document } + let kind: Kind + let token: String +} + +enum ShareURLParser { + + /// The `interlinedlist://` custom scheme the app registers for deep + /// links (matches the OAuth-callback scheme already handled in + /// `InterlinedListApp`). + static let scheme = "interlinedlist" + + /// Parses a URL into a `ParsedShare`, or returns `nil` when it is not a + /// share link. Accepts both `https` web URLs and the custom scheme; the + /// host is ignored for `https` (any interlinedlist host works) and + /// tolerated for the custom scheme (`lists`/`documents`/`share` may + /// appear as the host depending on how the OS composed the URL). + static func parse(_ url: URL) -> ParsedShare? { + // Normalize into path segments regardless of whether the resource + // words landed in the host or the path. `interlinedlist://lists/…` + // parses "lists" as the host, so fold host + path segments into one + // ordered list and match the trailing `/shared/`. + var segments: [String] = [] + if let host = url.host, !host.isEmpty, host != "interlinedlist.com", host != "www.interlinedlist.com" { + segments.append(host) + } + segments.append(contentsOf: url.pathComponents.filter { $0 != "/" && !$0.isEmpty }) + + return match(segments) + } + + /// Parses a raw string (e.g. from the paste field) into a + /// `ParsedShare`. Trims surrounding whitespace first so a pasted line + /// with trailing newline still parses. + static func parse(string: String) -> ParsedShare? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + return parse(url) + } + + /// Builds the canonical web share URL for a resource + token, used by + /// the share panel's copy affordance when the server did not return a + /// pre-built `ShareLink.url`. + static func webURL(base: URL, kind: ParsedShare.Kind, token: String) -> URL? { + let resource = kind == .list ? "lists" : "documents" + return base + .appendingPathComponent(resource) + .appendingPathComponent("shared") + .appendingPathComponent(token) + } + + // MARK: - Matching + + /// Matches `[… , , "shared", ]` at the tail of the + /// segment list. Anything else returns `nil`. + private static func match(_ segments: [String]) -> ParsedShare? { + // Ignore a leading "share" router segment if present. + let cleaned = segments.filter { $0.lowercased() != "share" } + guard cleaned.count >= 3 else { return nil } + let tail = Array(cleaned.suffix(3)) + let resource = tail[0].lowercased() + let sharedMarker = tail[1].lowercased() + let token = tail[2] + guard sharedMarker == "shared", !token.isEmpty else { return nil } + switch resource { + case "lists": return ParsedShare(kind: .list, token: token) + case "documents": return ParsedShare(kind: .document, token: token) + default: return nil + } + } +} diff --git a/App/Features/Social/ProfileHeaderView.swift b/App/Features/Social/ProfileHeaderView.swift index 7c785a8..eb0ca02 100644 --- a/App/Features/Social/ProfileHeaderView.swift +++ b/App/Features/Social/ProfileHeaderView.swift @@ -88,8 +88,17 @@ struct ProfileHeaderView: View { .textSelection(.enabled) } Spacer() - if let followButton { - FollowButton(viewModel: followButton) + HStack(spacing: 8) { + // Direct Messages (the-gaps.md G1) — additive "Message" + // affordance. Self-gating: it reads `AppEnvironment` + // itself and renders nothing unless the profiled user + // is an eligible recipient (mutual follower), so it + // never disrupts the follow button or the moderation + // menu. Pure presentation from the header's view. + ProfileMessageButton(username: profile.username) + if let followButton { + FollowButton(viewModel: followButton) + } } } @@ -132,6 +141,12 @@ struct ProfileHeaderView: View { .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .contain) .accessibilityLabel("Profile for \(profile.displayName), @\(profile.username)") + // Web-parity (the-gaps.md G2) — Block / Mute / Report on the + // profile via a right-click context menu. Additive: the header's + // own affordances (follow button, counts) are untouched. The + // modifier reads `AppEnvironment` itself, so this stays pure + // presentation from the header's perspective. + .moderationMenu(username: profile.username) } // MARK: - Subviews diff --git a/App/Features/Timeline/LinkPreviewCardView.swift b/App/Features/Timeline/LinkPreviewCardView.swift new file mode 100644 index 0000000..3474504 --- /dev/null +++ b/App/Features/Timeline/LinkPreviewCardView.swift @@ -0,0 +1,108 @@ +// LinkPreviewCardView +// +// Renders a single server-resolved rich link preview (feature-gaps §1.5) +// as a tappable card: an AsyncImage thumbnail (when the server resolved +// one), the preview title, and the link's host. The whole card is a +// button that opens the URL via the SwiftUI `@Environment(\.openURL)` +// action — no AppKit / `NSWorkspace`, honouring the App target's +// SwiftUI-only constraint. +// +// The "is this worth showing?" decision lives in the domain +// (`LinkPreview.isRenderable`), not here — the view stays passive and +// simply reflects a value that already passed that gate. Styling matches +// the surrounding timeline card theme (ILColor / ILFont / ILMetric). + +import SwiftUI +import InterlinedDomain + +struct LinkPreviewCardView: View { + + let preview: LinkPreview + + @Environment(\.openURL) private var openURL + + var body: some View { + Button { + openURL(preview.url) + } label: { + cardBody + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint("Opens the link in your browser") + .accessibilityAddTraits(.isLink) + } + + private var cardBody: some View { + HStack(alignment: .top, spacing: 10) { + if let imageURL = preview.imageURL { + thumbnail(imageURL) + } + VStack(alignment: .leading, spacing: 3) { + if let title = trimmedTitle { + Text(title) + .font(.ilBodyMedium()) + .foregroundStyle(ILColor.text) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + Text(preview.displayHost) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(10) + .background(ILColor.surface2, in: RoundedRectangle(cornerRadius: ILMetric.radiusLg)) + .overlay( + RoundedRectangle(cornerRadius: ILMetric.radiusLg) + .strokeBorder(ILColor.primary.opacity(0.15), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: ILMetric.radiusLg)) + } + + private func thumbnail(_ url: URL) -> some View { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + placeholderGlyph + case .empty: + // Loading — keep the slot sized so the layout doesn't jump. + Color.clear + @unknown default: + placeholderGlyph + } + } + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: ILMetric.radiusMd)) + .accessibilityHidden(true) + } + + private var placeholderGlyph: some View { + Image(systemName: "link") + .foregroundStyle(.secondary) + } + + /// The title with surrounding whitespace removed, or `nil` when the server + /// sent no title or a whitespace-only one — so the card renders host-only + /// rather than an empty title line. + private var trimmedTitle: String? { + guard let title = preview.title?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty else { return nil } + return title + } + + private var accessibilityLabel: String { + if let title = trimmedTitle { + return "Link preview: \(title), \(preview.displayHost)" + } + return "Link: \(preview.displayHost)" + } +} diff --git a/App/Features/Timeline/MessageRowView.swift b/App/Features/Timeline/MessageRowView.swift index 67bf775..dd54fb1 100644 --- a/App/Features/Timeline/MessageRowView.swift +++ b/App/Features/Timeline/MessageRowView.swift @@ -45,6 +45,19 @@ struct MessageRowView: View { /// The host is responsible for the confirmation dialog. var onDelete: ((Message) -> Void)? = nil + /// Optional block handler (the-gaps.md G2). When non-nil, a "Block + /// author" item is added to the overflow menu. The host performs the + /// moderation call and refreshes the timeline. + var onBlock: ((Message) -> Void)? = nil + + /// Optional mute handler. When non-nil, a "Mute author" item is added. + var onMute: ((Message) -> Void)? = nil + + /// Optional report handler. When non-nil, a "Report…" item opens the + /// host's report sheet for this message. Replaces the old + /// support-URL fallback so reporting is a real backend action. + var onReport: ((Message) -> Void)? = nil + var body: some View { VStack(alignment: .leading, spacing: 8) { header @@ -52,6 +65,9 @@ struct MessageRowView: View { repostBanner(original: repost.original) } bodyText + if !renderablePreviews.isEmpty { + linkPreviews + } if !message.tags.isEmpty { tagChips } @@ -121,6 +137,21 @@ struct MessageRowView: View { .fixedSize(horizontal: false, vertical: true) } + /// The subset of `message.linkPreviews` the domain deems worth showing + /// (feature-gaps §1.5). A bare URL with no resolved metadata is filtered + /// out here — the row degrades to no card rather than an empty one. + private var renderablePreviews: [LinkPreview] { + message.linkPreviews.filter(\.isRenderable) + } + + private var linkPreviews: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(renderablePreviews) { preview in + LinkPreviewCardView(preview: preview) + } + } + } + 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` @@ -231,17 +262,35 @@ struct MessageRowView: View { } } - // Report — visible for every message regardless of ownership - // (App Store Review Guideline 1.2: User-Generated Content requires - // a mechanism to report objectionable content). No backend report - // endpoint exists yet — open the support URL so users can contact - // the team directly. - Button { - if let url = URL(string: "https://interlinedlist.com/support") { - NSWorkspace.shared.open(url) + // Moderation (the-gaps.md G2). Block / Mute target the author; + // Report opens the host's report sheet for this message. All are + // ownership-independent (Report satisfies App Store Review + // Guideline 1.2: User-Generated Content requires a report + // mechanism). Each item renders only when its handler is wired so + // static / preview contexts stay clean; no AppKit involvement. + if onBlock != nil || onMute != nil || onReport != nil { + Divider() + } + if let onBlock { + Button { + onBlock(message) + } label: { + Label("Block @\(message.author.username)", systemImage: "hand.raised") + } + } + if let onMute { + Button { + onMute(message) + } label: { + Label("Mute @\(message.author.username)", systemImage: "speaker.slash") + } + } + if let onReport { + Button(role: .destructive) { + onReport(message) + } label: { + Label("Report\u{2026}", systemImage: "flag") } - } label: { - Label("Report\u{2026}", systemImage: "flag") } } diff --git a/App/Features/Timeline/TimelineRootView.swift b/App/Features/Timeline/TimelineRootView.swift index a4d3d96..ce05853 100644 --- a/App/Features/Timeline/TimelineRootView.swift +++ b/App/Features/Timeline/TimelineRootView.swift @@ -34,6 +34,12 @@ struct TimelineRootView: View { @State private var editTarget: Message? @State private var deleteTarget: Message? + // Web-parity (the-gaps.md G2) — moderation. The report sheet is + // driven by a `ModerationActionViewModel` built for the tapped + // message's author; block / mute fire directly against the + // moderation service. + @State private var reportActionVM: ModerationActionViewModel? + // M5.x — deep-link routing. When a system notification banner for a // message is tapped, `MainWindowView` sets this binding to the target // message ID before switching the sidebar to `.timeline`. The view @@ -91,6 +97,19 @@ struct TimelineRootView: View { .sheet(item: $editTarget) { target in ComposerWindowView(mode: .edit(messageID: target.id, original: target)) } + // Report sheet (the-gaps.md G2). Presented when the row's + // "Report…" item fires. The action VM carries the message id so + // it submits a message report; dismissing clears it. + .sheet( + isPresented: Binding( + get: { reportActionVM != nil }, + set: { if !$0 { reportActionVM = nil } } + ) + ) { + if let reportActionVM { + ReportReasonSheet(viewModel: reportActionVM) + } + } .confirmationDialog( "Delete this post?", isPresented: Binding( @@ -135,6 +154,28 @@ struct TimelineRootView: View { environment.map { ObjectIdentifier($0) } } + // MARK: - Moderation + + /// Blocks the message author, then refreshes the timeline so their + /// posts drop out of the feed. Errors are swallowed at the view + /// boundary — the timeline refresh reflects the authoritative state. + private func moderateBlock(author username: String) { + guard let environment else { return } + Task { + try? await environment.moderation.block(username: username) + await viewModel?.refresh() + } + } + + /// Mutes the message author, then refreshes the timeline. + private func moderateMute(author username: String) { + guard let environment else { return } + Task { + try? await environment.moderation.mute(username: username) + await viewModel?.refresh() + } + } + // MARK: - Body sections @ViewBuilder @@ -236,6 +277,19 @@ struct TimelineRootView: View { }, onDelete: { tapped in deleteTarget = tapped + }, + onBlock: { tapped in + moderateBlock(author: tapped.author.username) + }, + onMute: { tapped in + moderateMute(author: tapped.author.username) + }, + onReport: { tapped in + reportActionVM = ModerationActionViewModel( + username: tapped.author.username, + messageID: tapped.id, + service: environment?.moderation ?? NoopModerationService() + ) } ) } diff --git a/App/InterlinedListApp.swift b/App/InterlinedListApp.swift index df3635d..fe5e263 100644 --- a/App/InterlinedListApp.swift +++ b/App/InterlinedListApp.swift @@ -53,6 +53,15 @@ struct InterlinedListApp: App { /// keep the same instance across view rebuilds. @State private var dockBadge: NotificationsUnreadBadgeCoordinator? + /// Coordinator that turns DM-bus events into the DM contribution of + /// the dock badge (the-gaps.md G1). Held alongside `dockBadge`. + @State private var dmDockBadge: DirectMessagesUnreadBadgeCoordinator? + + /// Sums the notifications + DM unread contributions and performs the + /// single dock-badge write. Both coordinators report into this rather + /// than writing the badge directly, so neither clobbers the other. + @State private var badgeAggregator: UnreadBadgeAggregator? + var body: some Scene { WindowGroup { AppRootView(store: environment.currentUserStore) @@ -85,16 +94,41 @@ struct InterlinedListApp: App { // `NotificationsEventBus`; the tray view model // posts `trayRefreshed(...)` after every successful // load so the badge stays in sync without polling. - if dockBadge == nil { + if badgeAggregator == nil { let delegate = appDelegate - let coordinator = NotificationsUnreadBadgeCoordinator( - bus: environment.notificationsEventBus, + // Single writer of the dock badge. Both unread + // sources (notifications + DMs) report into it so + // the badge reflects their sum (the-gaps.md G1); + // neither coordinator writes the badge directly + // anymore, so they can't clobber each other. + let aggregator = UnreadBadgeAggregator( writeBadge: { @MainActor count in delegate.updateDockBadge(unreadCount: count) } ) - dockBadge = coordinator - coordinator.start() + badgeAggregator = aggregator + + // Notifications → `.notifications` slot. The + // coordinator's fold logic is unchanged; only its + // sink is now the aggregator instead of the badge. + let notificationsCoordinator = NotificationsUnreadBadgeCoordinator( + bus: environment.notificationsEventBus, + writeBadge: { @MainActor count in + aggregator.update(source: .notifications, count: count) + } + ) + dockBadge = notificationsCoordinator + notificationsCoordinator.start() + + // Direct Messages → `.directMessages` slot. + let dmCoordinator = DirectMessagesUnreadBadgeCoordinator( + bus: environment.directMessagesEventBus, + reportCount: { @MainActor count in + aggregator.update(source: .directMessages, count: count) + } + ) + dmDockBadge = dmCoordinator + dmCoordinator.start() } } } @@ -103,10 +137,12 @@ struct InterlinedListApp: App { // Wave 8.6 — Sparkle "Check for Updates..." in the app menu. UpdatesMenuCommands(sparkleController: sparkleController) AccountMenuCommands() + SearchMenuCommands() ComposeCommands() ListMenuCommands() DocumentsMenuCommands() NotificationsMenuCommands() + DirectMessagesMenuCommands() SocialMenuCommands() // M7 — CSV exports via File > Export submenu (PLAN.md §6 M7). ExportMenuCommands() @@ -163,6 +199,13 @@ private struct AppRootView: View { Task { try? await environment.session.signOut() } } .onOpenURL { url in + // Share Links (the-gaps.md G3) — a `…/lists/shared/{token}` or + // `…/documents/shared/{token}` URL (pasted, or delivered via the + // `interlinedlist://` scheme) routes to the resolve/claim landing. + // Handled first so it does not disturb the OAuth fallback below; + // `handle` returns `false` for any non-share URL. + if ShareLinkDeepLink.handle(url) { return } + // `interlinedlist://oauth/callback` is the native OAuth redirect URI // registered in Info.plist (NW-5). ASWebAuthenticationSession intercepts // the URL automatically; this handler is a fallback in case the system diff --git a/App/MenuCommands/DirectMessagesMenuCommands.swift b/App/MenuCommands/DirectMessagesMenuCommands.swift new file mode 100644 index 0000000..236db70 --- /dev/null +++ b/App/MenuCommands/DirectMessagesMenuCommands.swift @@ -0,0 +1,27 @@ +// DirectMessagesMenuCommands +// +// Menu-bar command for the Direct Messages feature (the-gaps.md G1). Adds +// a single "Messages" command that routes the sidebar to the Messages +// section: +// - Messages (⌥⌘M) — select the Messages sidebar row. +// +// Keybinding choice: ⌥⌘M is unused elsewhere (compose owns ⌘N / ⇧⌘N / +// ⌥⌘N, search owns ⌘F, notifications own ⌘0). ⌘M is reserved by macOS +// for Minimize, so we add the Option modifier. +// +// The command fans out via `NSNotification` — the same cross-scene channel +// the other menu commands use. `MainWindowView` observes +// `.directMessagesShow` to switch the sidebar. Pure SwiftUI. + +import SwiftUI + +struct DirectMessagesMenuCommands: Commands { + var body: some Commands { + CommandGroup(after: .toolbar) { + Button("Messages") { + NotificationCenter.default.post(name: .directMessagesShow, object: nil) + } + .keyboardShortcut("m", modifiers: [.command, .option]) + } + } +} diff --git a/App/MenuCommands/DocumentsMenuCommands.swift b/App/MenuCommands/DocumentsMenuCommands.swift index a1bc587..430b7e2 100644 --- a/App/MenuCommands/DocumentsMenuCommands.swift +++ b/App/MenuCommands/DocumentsMenuCommands.swift @@ -20,6 +20,13 @@ extension Notification.Name { /// in the currently-selected folder. static let documentsNewDocument = Notification.Name("InterlinedList.documentsNewDocument") + /// Posted when the user invokes Documents → New from Template…. + /// `DocumentsRootView` observes this and presents the template picker + /// sheet (feature-gaps.md §1.4). Client-side: seeds a new document from a + /// bundled starter-Markdown catalog, then routes it through the normal + /// create path. + static let documentsNewFromTemplate = Notification.Name("InterlinedList.documentsNewFromTemplate") + /// Posted when the user invokes Documents → Sync Now. The same /// notification fires from the toolbar button so the sync path is /// single-sourced. @@ -41,6 +48,13 @@ private struct DocumentsMenuButtons: View { } .keyboardShortcut("n", modifiers: [.option, .command]) + Button("New from Template…") { + NotificationCenter.default.post(name: .documentsNewFromTemplate, object: nil) + } + .keyboardShortcut("n", modifiers: [.option, .command, .shift]) + + Divider() + Button("Sync Now") { NotificationCenter.default.post(name: .documentsSyncNow, object: nil) } diff --git a/App/MenuCommands/SearchMenuCommands.swift b/App/MenuCommands/SearchMenuCommands.swift new file mode 100644 index 0000000..dd94227 --- /dev/null +++ b/App/MenuCommands/SearchMenuCommands.swift @@ -0,0 +1,54 @@ +// SearchMenuCommands +// +// Menu-bar command for the global search feature (the-gaps.md G5). Adds +// a single `Find` command under a dedicated menu: +// - Search… (⌘F) — route the sidebar to the Search section and focus +// the search field. +// +// Keybinding choice: +// - ⌘F is the platform-standard "Find" accelerator. It is unused +// elsewhere in the app (compose owns ⌘N / ⇧⌘N / ⌥⌘N, notifications +// own ⌘0, documents / lists own their own set), so ⌘F is free. +// +// The command fans out via `NSNotification` — the same cross-scene +// channel the other menu commands use. `MainWindowView` observes +// `.searchShow` to switch the sidebar; `SearchRootView` observes +// `.searchFocus` to focus the field. Pure SwiftUI; no AppKit involvement. + +import SwiftUI + +extension Foundation.Notification.Name { + /// Posted when the user invokes Find → Search…. `MainWindowView` + /// observes this and selects the `.search` sidebar row, then re-posts + /// `.searchFocus` so the field takes focus once the view is on screen. + static let searchShow = Foundation.Notification.Name("InterlinedList.searchShow") + + /// Posted after the sidebar has switched to `.search`. `SearchRootView` + /// observes this and focuses its text field. + static let searchFocus = Foundation.Notification.Name("InterlinedList.searchFocus") + + /// Posted when the user taps a document hit in the search results. + /// `MainWindowView` observes this and selects the `.documents` + /// sidebar row (there is no typed `.documents` deep-link target). + static let searchShowDocuments = Foundation.Notification.Name("InterlinedList.searchShowDocuments") +} + +struct SearchMenuCommands: Commands { + var body: some Commands { + // Place the Find command in the standard text-editing menu area by + // using `.textEditing`; a dedicated `CommandMenu` keeps it discoverable + // even when no text field owns first responder. + CommandMenu("Find") { + SearchMenuButtons() + } + } +} + +private struct SearchMenuButtons: View { + var body: some View { + Button("Search\u{2026}") { + NotificationCenter.default.post(name: .searchShow, object: nil) + } + .keyboardShortcut("f", modifiers: [.command]) + } +} diff --git a/App/Navigation/MainWindowView.swift b/App/Navigation/MainWindowView.swift index 7ecec88..a989462 100644 --- a/App/Navigation/MainWindowView.swift +++ b/App/Navigation/MainWindowView.swift @@ -18,9 +18,11 @@ import InterlinedDomain /// requests roster panel — added in Wave 6.3 to surface the /// dedicated Requests management UI alongside the inline tray rows). enum SidebarSection: String, CaseIterable, Identifiable, Hashable { + case search = "Search" case timeline = "Timeline" case scheduled = "Scheduled" case notifications = "Notifications" + case messages = "Messages" case lists = "Lists" case documents = "Documents" case organizations = "Organizations" @@ -32,9 +34,11 @@ enum SidebarSection: String, CaseIterable, Identifiable, Hashable { /// SF Symbol name for the row icon. Pure presentation hint; no semantics. var systemImage: String { switch self { + case .search: return "magnifyingglass" case .timeline: return "house" case .scheduled: return "calendar" case .notifications: return "bell" + case .messages: return "bubble.left.and.bubble.right" case .lists: return "list.bullet.rectangle" case .documents: return "doc.text" case .organizations: return "building.2" @@ -71,9 +75,17 @@ struct MainWindowView: View { // re-navigate. @State private var pendingMessageDeepLinkID: String? = nil + // Share Links (the-gaps.md G3) — when an opened share URL resolves to a + // `ParsedShare`, `ShareLinkDeepLink` posts `.openShareLink` and this + // state drives the `ResolveShareView` landing sheet. + @State private var pendingShare: ParsedShare? = nil + var body: some View { NavigationSplitView { List(selection: $selection) { + Label(SidebarSection.search.rawValue, systemImage: SidebarSection.search.systemImage) + .tag(SidebarSection.search) + .foregroundStyle(ILColor.onMasthead) Label(SidebarSection.timeline.rawValue, systemImage: SidebarSection.timeline.systemImage) .tag(SidebarSection.timeline) .foregroundStyle(ILColor.onMasthead) @@ -84,6 +96,9 @@ struct MainWindowView: View { Label(SidebarSection.notifications.rawValue, systemImage: SidebarSection.notifications.systemImage) .tag(SidebarSection.notifications) .foregroundStyle(ILColor.onMasthead) + Label(SidebarSection.messages.rawValue, systemImage: SidebarSection.messages.systemImage) + .tag(SidebarSection.messages) + .foregroundStyle(ILColor.onMasthead) Label(SidebarSection.lists.rawValue, systemImage: SidebarSection.lists.systemImage) .tag(SidebarSection.lists) .foregroundStyle(ILColor.onMasthead) @@ -139,6 +154,27 @@ struct MainWindowView: View { .onReceive(NotificationCenter.default.publisher(for: .notificationsShow)) { _ in selection = .notifications } + // Web-parity (the-gaps.md G5) — the ⌘F menu command posts + // `.searchShow`; switch the sidebar to Search, then re-post + // `.searchFocus` so the field takes focus once it is on screen. + .onReceive(NotificationCenter.default.publisher(for: .searchShow)) { _ in + selection = .search + NotificationCenter.default.post(name: .searchFocus, object: nil) + } + // A document search hit has no typed deep-link target, so the + // search view routes here to land the user on the Documents + // section. + .onReceive(NotificationCenter.default.publisher(for: .searchShowDocuments)) { _ in + selection = .documents + } + // Direct Messages (the-gaps.md G1) — the ⌥⌘M menu command and the + // profile "Message" button post `.directMessagesShow` to route the + // sidebar to Messages. The `.directMessagesOpenThread` event that + // may accompany it is observed by `DirectMessagesRootView` itself, + // which selects the target conversation once it is on screen. + .onReceive(NotificationCenter.default.publisher(for: .directMessagesShow)) { _ in + selection = .messages + } // M5.x — System notification banner deep-link. `AppDelegate` posts // `.notificationDeepLink` with the resolved `NotificationTarget` as // the `object` when the user taps a delivered banner. We switch the @@ -195,6 +231,26 @@ struct MainWindowView: View { .sheet(isPresented: $showExportSheet, onDismiss: { pendingExportType = nil }) { ExportView(initialExportType: pendingExportType) } + // Share Links (the-gaps.md G3) — an opened share URL posts + // `.openShareLink` with the `ParsedShare`. Present the resolve/claim + // landing; on a successful claim, route the sidebar to the resource + // section so the user lands where they now have access. + .onReceive(NotificationCenter.default.publisher(for: .openShareLink)) { note in + guard let parsed = note.object as? ParsedShare else { return } + pendingShare = parsed + } + .sheet( + isPresented: Binding( + get: { pendingShare != nil }, + set: { if !$0 { pendingShare = nil } } + ) + ) { + if let parsed = pendingShare { + ResolveShareView(parsed: parsed, environment: environment) { _ in + selection = parsed.kind == .list ? .lists : .documents + } + } + } } } @@ -228,6 +284,11 @@ private struct SidebarDetailDispatcher: View { var body: some View { switch section { + case .search: + // Web-parity (the-gaps.md G5) — the global search surface + // over messages / lists / documents. Routed from the sidebar + // and from the ⌘F menu command. + SearchRootView() case .timeline: TimelineRootView(pendingDeepLinkMessageID: $pendingMessageDeepLinkID) case .scheduled: @@ -237,6 +298,12 @@ private struct SidebarDetailDispatcher: View { ScheduledPostsRootView() case .notifications: NotificationsRootView() + case .messages: + // Web-parity (the-gaps.md G1) — the Direct Messages surface: + // folder switcher → conversation list → thread. Routed from + // the sidebar, the ⌥⌘M menu command, and the profile + // "Message" button. + DirectMessagesRootView() case .lists: // M3 (Wave 4.3) — sign-in routing: // • Signed-in users get `OwnedListsRootView` (Lists CRUD). diff --git a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/InterlinedList.helpindex b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/InterlinedList.helpindex index 50f68ba..ee66953 100644 Binary files a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/InterlinedList.helpindex and b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/InterlinedList.helpindex differ diff --git a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html index 6c1554a..5ac8a36 100644 --- a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html +++ b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html @@ -51,7 +51,7 @@

Coming in a future update

  • Composing posts, replies, reposts, and "I Dig!" reactions.
  • Creating and editing your own lists.
  • Documents with offline editing and sync.
  • -
  • Notifications, organizations, scheduled posts, cross-posting, media attachments, and CSV exports.
  • +
  • Notifications, organizations, scheduled posts, media attachments, and CSV exports.
  • Back to Help home

    diff --git a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html index 4d0dc1b..d6a98ca 100644 --- a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html +++ b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html @@ -52,7 +52,7 @@

    Known limits in this release

  • Following and unfollowing users; follower and following lists; mutual follows; private-account requests.
  • Notifications (sidebar badge, tray, and system notifications).
  • Organizations and member management.
  • -
  • Media attachments, scheduled posts, and cross-posting to Mastodon, Bluesky, and LinkedIn.
  • +
  • Media attachments and scheduled posts.
  • OAuth identity linking (GitHub, Mastodon, Bluesky, LinkedIn).
  • CSV exports.
  • Settings polish: email change, account deletion, avatar upload.
  • diff --git a/AppTests/BlockedAndMutedViewModelTests.swift b/AppTests/BlockedAndMutedViewModelTests.swift new file mode 100644 index 0000000..baa9f0b --- /dev/null +++ b/AppTests/BlockedAndMutedViewModelTests.swift @@ -0,0 +1,134 @@ +// BlockedAndMutedViewModelTests +// +// BDD-named tests for the Settings "Blocked & Muted" view model +// (the-gaps.md G2). Covers the required quartet plus the optimistic- +// removal rollback: +// - happy: load populates both rosters. +// - invalid input (unblock unknown username): no service call, no row +// change. +// - upstream failure (load): surfaces error, sets hasLoadedOnce. +// - empty / boundary: load with two empty rosters reports empty + +// hasLoadedOnce. +// - unblock happy: optimistic row removal + service call. +// - unblock rollback: failure restores the removed row + surfaces error. +// - unmute happy + rollback: same shape on the muted roster. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class BlockedAndMutedViewModelTests: XCTestCase { + + // MARK: - Helpers + + private func makeViewModel() -> (BlockedAndMutedViewModel, StubModerationService) { + let service = StubModerationService() + let vm = BlockedAndMutedViewModel(service: service) + return (vm, service) + } + + private func user(_ username: String) -> ModeratedUser { + ModeratedUser(id: "id-\(username)", username: username, displayName: username.capitalized, avatarURL: nil) + } + + // MARK: - load + + func test_givenBlockedAndMuted_whenLoading_thenBothRostersPopulate() async { + let (vm, service) = makeViewModel() + await service.enqueueBlocked(success: [user("alice"), user("bob")]) + await service.enqueueMuted(success: [user("carol")]) + + await vm.load() + + XCTAssertEqual(vm.blocked.map(\.username), ["alice", "bob"]) + XCTAssertEqual(vm.muted.map(\.username), ["carol"]) + XCTAssertNil(vm.error) + XCTAssertTrue(vm.hasLoadedOnce) + } + + func test_givenEmptyRosters_whenLoading_thenReportsEmptyAndHasLoadedOnce() async { + let (vm, service) = makeViewModel() + await service.enqueueBlocked(success: []) + await service.enqueueMuted(success: []) + + await vm.load() + + XCTAssertTrue(vm.blocked.isEmpty) + XCTAssertTrue(vm.muted.isEmpty) + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertNil(vm.error) + } + + func test_givenLoadFailure_whenLoading_thenSurfacesErrorAndHasLoadedOnce() async { + let (vm, service) = makeViewModel() + await service.enqueueBlocked(failure: TestError.upstream("net")) + await service.enqueueMuted(success: []) + + await vm.load() + + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + XCTAssertTrue(vm.hasLoadedOnce) + } + + // MARK: - unblock + + func test_givenBlockedUser_whenUnblocking_thenRemovesRowAndCallsService() async { + let (vm, service) = makeViewModel() + vm.seedForTest(blocked: [user("alice"), user("bob")], muted: []) + await service.enqueueUnblockSuccess() + + await vm.unblock(username: "alice") + + XCTAssertEqual(vm.blocked.map(\.username), ["bob"]) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertTrue(recorded.contains { if case .unblock(let u) = $0.kind { return u == "alice" } else { return false } }) + } + + func test_givenUnknownUsername_whenUnblocking_thenServiceIsNotCalled() async { + let (vm, service) = makeViewModel() + vm.seedForTest(blocked: [user("alice")], muted: []) + + await vm.unblock(username: "nobody") + + XCTAssertEqual(vm.blocked.map(\.username), ["alice"]) + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "Unblocking an absent row must not call the service") + } + + func test_givenUnblockFailure_whenUnblocking_thenRestoresRowAndSurfacesError() async { + let (vm, service) = makeViewModel() + vm.seedForTest(blocked: [user("alice"), user("bob")], muted: []) + await service.enqueueUnblock(failure: TestError.upstream("net")) + + await vm.unblock(username: "alice") + + XCTAssertEqual(vm.blocked.map(\.username), ["alice", "bob"], "Failed unblock must restore the row") + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + } + + // MARK: - unmute + + func test_givenMutedUser_whenUnmuting_thenRemovesRowAndCallsService() async { + let (vm, service) = makeViewModel() + vm.seedForTest(blocked: [], muted: [user("carol"), user("dan")]) + await service.enqueueUnmuteSuccess() + + await vm.unmute(username: "carol") + + XCTAssertEqual(vm.muted.map(\.username), ["dan"]) + XCTAssertNil(vm.error) + } + + func test_givenUnmuteFailure_whenUnmuting_thenRestoresRowAndSurfacesError() async { + let (vm, service) = makeViewModel() + vm.seedForTest(blocked: [], muted: [user("carol"), user("dan")]) + await service.enqueueUnmute(failure: TestError.upstream("net")) + + await vm.unmute(username: "carol") + + XCTAssertEqual(vm.muted.map(\.username), ["carol", "dan"]) + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + } +} diff --git a/AppTests/ComposerViewModelTests.swift b/AppTests/ComposerViewModelTests.swift index 1ac24a5..59f4871 100644 --- a/AppTests/ComposerViewModelTests.swift +++ b/AppTests/ComposerViewModelTests.swift @@ -41,7 +41,7 @@ final class ComposerViewModelTests: XCTestCase { // M6 options for a plain post. let recorded = await stub.recorded XCTAssertEqual(recorded.count, 1) - if case .createPost(let body, let tags, let visibility, let imageURLs, let videoURLs, let scheduledAt, let mastodon, let bluesky, let linkedIn) = recorded.first?.kind { + if case .createPost(let body, let tags, let visibility, let imageURLs, let videoURLs, let scheduledAt, let mastodon, let bluesky, let linkedIn, let twitter) = recorded.first?.kind { XCTAssertEqual(body, "hello") XCTAssertEqual(tags, []) XCTAssertEqual(visibility, .public) @@ -51,6 +51,7 @@ final class ComposerViewModelTests: XCTestCase { XCTAssertTrue(mastodon.isEmpty) XCTAssertFalse(bluesky) XCTAssertFalse(linkedIn) + XCTAssertFalse(twitter) } else { XCTFail("Expected a `createPost` call, got \(String(describing: recorded.first))") } @@ -240,7 +241,7 @@ final class ComposerViewModelTests: XCTestCase { guard case .uploadImage = recorded[0].kind else { return XCTFail("Expected uploadImage first, got \(recorded[0].kind)") } - if case .createPost(_, _, _, let imageURLs, let videoURLs, _, _, _, _) = recorded[1].kind { + if case .createPost(_, _, _, let imageURLs, let videoURLs, _, _, _, _, _) = recorded[1].kind { XCTAssertEqual(imageURLs, ["https://cdn/uploaded.png"]) XCTAssertTrue(videoURLs.isEmpty) } else { @@ -269,7 +270,7 @@ final class ComposerViewModelTests: XCTestCase { } else { XCTFail("Expected uploadVideo, got \(String(describing: recorded.first?.kind))") } - if case .createPost(_, _, _, _, let videoURLs, _, _, _, _) = recorded.last?.kind { + if case .createPost(_, _, _, _, let videoURLs, _, _, _, _, _) = recorded.last?.kind { XCTAssertEqual(videoURLs, ["https://cdn/uploaded.mp4"]) } else { XCTFail("Expected createPost, got \(String(describing: recorded.last?.kind))") @@ -334,7 +335,7 @@ final class ComposerViewModelTests: XCTestCase { // Then let recorded = await stub.recorded - if case .createPost(_, _, _, _, _, let scheduledAt, _, _, _) = recorded.first?.kind { + if case .createPost(_, _, _, _, _, let scheduledAt, _, _, _, _) = recorded.first?.kind { XCTAssertEqual(scheduledAt, when) } else { XCTFail("Expected createPost with scheduledAt, got \(String(describing: recorded.first?.kind))") @@ -365,7 +366,7 @@ final class ComposerViewModelTests: XCTestCase { // MARK: - M6 cross-post flag passthrough func test_givenCrossPostToggles_whenSubmitting_thenPassesFlagsAndProviderIds() async throws { - // Given — a subscriber enables Mastodon (with ids), Bluesky, LinkedIn. + // Given — a subscriber enables Mastodon (with ids), Bluesky, LinkedIn, X. let stub = StubMessagesService() await stub.enqueueCreatePost(success: MessageFixtures.message(id: "m-xpost")) let viewModel = subscriberViewModel(messages: stub) @@ -374,22 +375,73 @@ final class ComposerViewModelTests: XCTestCase { viewModel.mastodonProviderIdsInput = "p-1, p-2 p-2" viewModel.crossPostToBluesky = true viewModel.crossPostToLinkedIn = true + viewModel.crossPostToTwitter = true // When await viewModel.submit() // Then let recorded = await stub.recorded - if case .createPost(_, _, _, _, _, _, let mastodon, let bluesky, let linkedIn) = recorded.first?.kind { + if case .createPost(_, _, _, _, _, _, let mastodon, let bluesky, let linkedIn, let twitter) = recorded.first?.kind { XCTAssertEqual(mastodon, ["p-1", "p-2"]) // deduped, order preserved XCTAssertTrue(bluesky) XCTAssertTrue(linkedIn) + XCTAssertTrue(twitter) } else { XCTFail("Expected createPost, got \(String(describing: recorded.first?.kind))") } XCTAssertTrue(viewModel.didFinish) } + // MARK: - G7 X / Twitter cross-post flag passthrough + + func test_givenTwitterToggleOnly_whenSubmitting_thenPassesTwitterFlagAndLeavesOthersOff() async throws { + // Happy path (G7): enabling only the X toggle threads + // `crossPostToTwitter: true` through `createPost` while every other + // cross-post target stays off — proving the X flag is wired + // independently, exactly like LinkedIn. + let stub = StubMessagesService() + await stub.enqueueCreatePost(success: MessageFixtures.message(id: "m-x-only")) + let viewModel = subscriberViewModel(messages: stub) + viewModel.body = "hello X" + viewModel.crossPostToTwitter = true + + // When + await viewModel.submit() + + // Then + let recorded = await stub.recorded + if case .createPost(_, _, _, _, _, _, let mastodon, let bluesky, let linkedIn, let twitter) = recorded.first?.kind { + XCTAssertTrue(twitter) + XCTAssertTrue(mastodon.isEmpty) + XCTAssertFalse(bluesky) + XCTAssertFalse(linkedIn) + } else { + XCTFail("Expected createPost, got \(String(describing: recorded.first?.kind))") + } + XCTAssertTrue(viewModel.didFinish) + } + + func test_givenTwitterToggleDefault_whenSubmittingPlainPost_thenTwitterFlagIsFalse() async throws { + // Boundary (G7): a plain post never opts into X — the default + // `crossPostToTwitter` is false and must reach `createPost` as false. + let stub = StubMessagesService() + await stub.enqueueCreatePost(success: MessageFixtures.message(id: "m-x-default")) + let viewModel = subscriberViewModel(messages: stub) + viewModel.body = "plain" + + // When + await viewModel.submit() + + // Then + let recorded = await stub.recorded + if case .createPost(_, _, _, _, _, _, _, _, _, let twitter) = recorded.first?.kind { + XCTAssertFalse(twitter) + } else { + XCTFail("Expected createPost, got \(String(describing: recorded.first?.kind))") + } + } + func test_givenMastodonToggledOff_whenSubmitting_thenSendsNoProviderIds() async throws { // Given — boundary: provider-id text present but the Mastodon toggle // is off, so the ids must NOT be sent. @@ -405,7 +457,7 @@ final class ComposerViewModelTests: XCTestCase { // Then let recorded = await stub.recorded - if case .createPost(_, _, _, _, _, _, let mastodon, _, _) = recorded.first?.kind { + if case .createPost(_, _, _, _, _, _, let mastodon, _, _, _) = recorded.first?.kind { XCTAssertTrue(mastodon.isEmpty) } else { XCTFail("Expected createPost, got \(String(describing: recorded.first?.kind))") diff --git a/AppTests/DMThreadViewModelTests.swift b/AppTests/DMThreadViewModelTests.swift new file mode 100644 index 0000000..70b5ef1 --- /dev/null +++ b/AppTests/DMThreadViewModelTests.swift @@ -0,0 +1,222 @@ +// DMThreadViewModelTests +// +// BDD-named tests for the DM thread view model (the-gaps.md G1). Covers +// the required quartet plus the optimistic-send rollback, mark-read-on- +// open, and the poll lifecycle (cancellation): +// - happy: send appends optimistically then replaces with the server +// message. +// - invalid input: a blank draft never calls the service. +// - upstream failure: a failing send removes the placeholder, restores +// the draft, and surfaces the error. +// - empty / boundary: loading an empty thread reports an empty list. +// - optimistic rollback: asserted in the upstream-failure case. +// - mark-read: opening a thread marks inbound-unread messages read and +// posts the events. +// - poll cancellation: stopPolling() ends the loop; no further +// threadUpdates land after teardown. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class DMThreadViewModelTests: XCTestCase { + + // MARK: - Helpers + + private let me = "user-me" + private let otherId = "user-ada" + + private func makeViewModel(pollInterval: Duration = .milliseconds(5)) -> (DMThreadViewModel, StubDirectMessagesService, DirectMessagesEventBus) { + let service = StubDirectMessagesService() + let bus = DirectMessagesEventBus() + let vm = DMThreadViewModel( + username: "ada", + service: service, + eventBus: bus, + currentUserID: { [me] in me }, + pollInterval: pollInterval + ) + return (vm, service, bus) + } + + private var ada: UserSummary { + UserSummary(id: otherId, username: "ada", displayName: "Ada", avatarURL: nil) + } + + private func inbound(_ id: String, read: Bool = false, at: TimeInterval) -> DirectMessage { + DirectMessage( + id: id, senderId: otherId, recipientId: me, body: "in \(id)", + createdAt: Date(timeIntervalSince1970: at), + readAt: read ? Date(timeIntervalSince1970: at) : nil, + sender: ada, recipient: nil + ) + } + + private func outbound(_ id: String, at: TimeInterval) -> DirectMessage { + DirectMessage( + id: id, senderId: me, recipientId: otherId, body: "out \(id)", + createdAt: Date(timeIntervalSince1970: at), + readAt: nil, sender: nil, recipient: ada + ) + } + + private func serverMessage(_ id: String, body: String) -> DirectMessage { + DirectMessage( + id: id, senderId: me, recipientId: otherId, body: body, + createdAt: Date(timeIntervalSince1970: 9_000), readAt: nil, + sender: nil, recipient: ada + ) + } + + // MARK: - Happy path (optimistic send → server replace) + + func test_givenMutualThread_whenSending_thenReplacesOptimisticWithServerMessage() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true) + await service.enqueueSend(success: serverMessage("server-1", body: "hi")) + + vm.draft = "hi" + await vm.send() + + XCTAssertEqual(vm.messages.map(\.id), ["m1", "server-1"], "Placeholder replaced by server id") + XCTAssertEqual(vm.messages.last?.body, "hi") + XCTAssertEqual(vm.draft, "", "Draft cleared on success") + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .send(recipientId: otherId, body: "hi", imageURLs: [])))) + } + + // MARK: - Invalid input (blank draft → no service call) + + func test_givenBlankDraft_whenSending_thenServiceIsNotCalled() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true) + + vm.draft = " \n " + await vm.send() + + let recorded = await service.recorded + XCTAssertFalse(recorded.contains(where: { if case .send = $0.kind { return true } else { return false } }), + "A blank draft must not call send") + XCTAssertEqual(vm.messages.count, 1, "No optimistic placeholder appended") + } + + // MARK: - Upstream API failure (optimistic rollback) + + func test_givenSendFails_whenSending_thenRemovesPlaceholderAndRestoresDraft() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true) + await service.enqueueSend(failure: TestError.upstream("offline")) + + vm.draft = "hello" + await vm.send() + + XCTAssertEqual(vm.messages.map(\.id), ["m1"], "Optimistic placeholder removed on failure") + XCTAssertEqual(vm.draft, "hello", "Draft restored so the user doesn't lose their text") + XCTAssertEqual(vm.error as? TestError, .upstream("offline")) + } + + // MARK: - Empty / boundary + + func test_givenEmptyThread_whenLoading_thenReportsEmptyAndHasLoadedOnce() async { + let (vm, service, _) = makeViewModel() + await service.enqueueThread(success: DMThread(messages: [], otherUser: ada, isMutual: true)) + + await vm.load() + + XCTAssertTrue(vm.messages.isEmpty) + XCTAssertEqual(vm.otherUser?.username, "ada") + XCTAssertTrue(vm.isMutual) + XCTAssertTrue(vm.hasLoadedOnce) + } + + // MARK: - Non-mutual gate + + func test_givenNonMutualThread_whenLoaded_thenCannotSend() async { + let (vm, service, _) = makeViewModel() + await service.enqueueThread(success: DMThread(messages: [], otherUser: ada, isMutual: false)) + + await vm.load() + vm.draft = "hi" + + XCTAssertFalse(vm.canSend, "A non-mutual recipient can't be messaged") + } + + // MARK: - Mark read on open + + func test_givenUnreadInbound_whenMarkingRead_thenCallsServiceAndPostsEvents() async { + let (vm, service, bus) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", at: 100), inbound("m2", read: true, at: 200)], otherUser: ada, isMutual: true) + await service.enqueueMarkReadSuccess() + await service.enqueueUnreadCount(success: 0) + + // Capture the threadRead event. + let readEvent = expectation(description: "threadRead") + let task = Task { + for await event in bus.events() { + if case .threadRead(let username) = event, username == "ada" { + readEvent.fulfill(); return + } + } + } + try? await Task.sleep(nanoseconds: 10_000_000) + + await vm.markInboundRead() + + await fulfillment(of: [readEvent], timeout: 1.0) + task.cancel() + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .markRead(id: "m1"))), "Only the unread inbound message is marked") + XCTAssertFalse(recorded.contains(.init(kind: .markRead(id: "m2"))), "The already-read message is skipped") + } + + func test_givenNoUnreadInbound_whenMarkingRead_thenServiceIsNotCalled() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [outbound("m1", at: 100)], otherUser: ada, isMutual: true) + + await vm.markInboundRead() + + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "An all-outbound thread marks nothing") + } + + // MARK: - Poll cycle + cancellation + + func test_givenPollCycle_whenNewMessageArrives_thenMergesIntoThread() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true) + // pollOnce fetches threadUpdates then marks the new inbound read. + await service.enqueueThreadUpdates(success: DMThread(messages: [inbound("m2", at: 200)], otherUser: ada, isMutual: true)) + await service.enqueueMarkReadSuccess() + await service.enqueueUnreadCount(success: 0) + + await vm.pollOnce() + + XCTAssertEqual(vm.messages.map(\.id), ["m1", "m2"], "The polled message merged in place") + } + + func test_givenRunningPoll_whenStopped_thenNoFurtherUpdatesLand() async { + let (vm, service, _) = makeViewModel(pollInterval: .milliseconds(5)) + // Initial load for startPolling. + await service.enqueueThread(success: DMThread(messages: [inbound("m1", read: true, at: 100)], otherUser: ada, isMutual: true)) + // startPolling calls markInboundRead — nothing unread here, so no + // markRead is consumed. Provide a generous number of threadUpdates + // outcomes so an un-cancelled loop would consume them; after + // stopPolling the count must stop growing. + for _ in 0..<20 { + await service.enqueueThreadUpdates(success: DMThread(messages: [], otherUser: ada, isMutual: true)) + } + + await vm.startPolling() + // Let a few poll cycles run. + try? await Task.sleep(nanoseconds: 40_000_000) + vm.stopPolling() + let countAfterStop = await service.recorded.filter { if case .threadUpdates = $0.kind { return true } else { return false } }.count + // Wait well past several more intervals; the count must not grow. + try? await Task.sleep(nanoseconds: 60_000_000) + let countLater = await service.recorded.filter { if case .threadUpdates = $0.kind { return true } else { return false } }.count + + XCTAssertEqual(countAfterStop, countLater, "No threadUpdates fire after stopPolling") + } +} diff --git a/AppTests/DirectMessagesListViewModelTests.swift b/AppTests/DirectMessagesListViewModelTests.swift new file mode 100644 index 0000000..db31b06 --- /dev/null +++ b/AppTests/DirectMessagesListViewModelTests.swift @@ -0,0 +1,243 @@ +// DirectMessagesListViewModelTests +// +// BDD-named tests for the DM conversation-list view model (the-gaps.md +// G1). Covers the required quartet plus pagination and the optimistic +// trash/restore rollback: +// - happy: a folder load groups the flat listing into conversations. +// - invalid input: trashing an id not in the list makes no service call. +// - upstream failure: a failing folder load surfaces the error. +// - empty / boundary: an empty page reports an empty list + hasLoadedOnce. +// - pagination: nextCursor is surfaced (hasMore) and loadMore appends; +// a zero-item page boundary clears hasMore. +// - optimistic: trash drops the row locally; a failing trash restores it. +// +// Tests drive the view model through its intents and await, so assertions +// are deterministic. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class DirectMessagesListViewModelTests: XCTestCase { + + // MARK: - Helpers + + private let me = "user-me" + + private func makeViewModel() -> (DirectMessagesListViewModel, StubDirectMessagesService, DirectMessagesEventBus) { + let service = StubDirectMessagesService() + let bus = DirectMessagesEventBus() + let vm = DirectMessagesListViewModel( + service: service, + eventBus: bus, + currentUserID: { [me] in me } + ) + return (vm, service, bus) + } + + private func other(_ id: String, _ username: String) -> UserSummary { + UserSummary(id: id, username: username, displayName: username.capitalized, avatarURL: nil) + } + + /// An inbound message (they → me). + private func inbound(_ id: String, from otherId: String, username: String, read: Bool = false, at: TimeInterval) -> DirectMessage { + DirectMessage( + id: id, + senderId: otherId, + recipientId: me, + body: "in \(id)", + createdAt: Date(timeIntervalSince1970: at), + readAt: read ? Date(timeIntervalSince1970: at) : nil, + sender: other(otherId, username), + recipient: nil + ) + } + + // MARK: - Happy path + + func test_givenFolderPage_whenLoading_thenGroupsMessagesIntoConversations() async { + let (vm, service, _) = makeViewModel() + await service.enqueueFolder(success: DMPage(messages: [ + inbound("m3", from: "user-ada", username: "ada", at: 300), + inbound("m2", from: "user-ada", username: "ada", read: true, at: 200), + inbound("m1", from: "user-bob", username: "bob", at: 100) + ], nextCursor: nil)) + await service.enqueueUnreadCount(success: 1) + + await vm.load() + + XCTAssertEqual(vm.conversations.count, 2, "Two distinct participants → two rows") + XCTAssertEqual(vm.conversations.first?.otherUsername, "ada") + XCTAssertEqual(vm.conversations.first?.latestMessage.id, "m3", "Newest message is the preview") + XCTAssertEqual(vm.conversations.first?.unreadCount, 1, "m3 unread, m2 read") + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertNil(vm.error) + } + + // MARK: - Invalid input (no-op trash of an absent id) + + func test_givenMessageIdNotInList_whenTrashing_thenServiceIsNotCalled() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)]) + + await vm.trash(messageID: "does-not-exist") + + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "Trashing an absent id must not call the service") + XCTAssertEqual(vm.conversations.count, 1, "The list is untouched") + } + + // MARK: - Upstream API failure + + func test_givenUpstreamFailure_whenLoading_thenSurfacesErrorAndHasLoadedOnce() async { + let (vm, service, _) = makeViewModel() + await service.enqueueFolder(failure: TestError.upstream("net")) + await service.enqueueUnreadCount(success: 0) + + await vm.load() + + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + XCTAssertTrue(vm.conversations.isEmpty) + XCTAssertTrue(vm.hasLoadedOnce) + } + + // MARK: - Empty / boundary + + func test_givenEmptyPage_whenLoading_thenReportsEmptyAndHasLoadedOnce() async { + let (vm, service, _) = makeViewModel() + await service.enqueueFolder(success: .empty) + await service.enqueueUnreadCount(success: 0) + + await vm.load() + + XCTAssertTrue(vm.conversations.isEmpty) + XCTAssertFalse(vm.hasMore) + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertNil(vm.error) + } + + // MARK: - Pagination + + func test_givenNextCursor_whenLoading_thenHasMoreIsSurfaced() async { + let (vm, service, _) = makeViewModel() + await service.enqueueFolder(success: DMPage( + messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)], + nextCursor: "cursor-2" + )) + await service.enqueueUnreadCount(success: 0) + + await vm.load() + + XCTAssertTrue(vm.hasMore) + XCTAssertEqual(vm.nextCursor, "cursor-2") + } + + func test_givenNextCursor_whenLoadingMore_thenAppendsAndClearsCursorOnZeroItemPage() async { + let (vm, service, _) = makeViewModel() + await service.enqueueFolder(success: DMPage( + messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)], + nextCursor: "cursor-2" + )) + await service.enqueueUnreadCount(success: 0) + await vm.load() + XCTAssertTrue(vm.hasMore) + + // Zero-item next page → hasMore clears. + await service.enqueueFolder(success: DMPage(messages: [], nextCursor: nil)) + await vm.loadMore() + + XCTAssertFalse(vm.hasMore, "A nil next cursor exhausts pagination") + XCTAssertEqual(vm.conversations.count, 1, "No new rows from the empty page") + } + + func test_givenNextCursor_whenLoadingMore_thenSecondParticipantAppends() async { + let (vm, service, _) = makeViewModel() + await service.enqueueFolder(success: DMPage( + messages: [inbound("m1", from: "user-ada", username: "ada", at: 200)], + nextCursor: "cursor-2" + )) + await service.enqueueUnreadCount(success: 1) + await vm.load() + + await service.enqueueFolder(success: DMPage( + messages: [inbound("m2", from: "user-bob", username: "bob", at: 100)], + nextCursor: nil + )) + await vm.loadMore() + + XCTAssertEqual(vm.conversations.map(\.otherUsername), ["ada", "bob"]) + XCTAssertFalse(vm.hasMore) + } + + // MARK: - Optimistic trash / restore + + func test_givenConversation_whenTrashing_thenDropsRowAndCallsService() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [ + inbound("m1", from: "user-ada", username: "ada", at: 200), + inbound("m2", from: "user-bob", username: "bob", at: 100) + ]) + await service.enqueueTrashSuccess() + await service.enqueueUnreadCount(success: 0) + + await vm.trash(messageID: "m1") + + XCTAssertEqual(vm.conversations.map(\.otherUsername), ["bob"], "The trashed conversation is gone") + let recorded = await service.recorded + XCTAssertTrue(recorded.contains(.init(kind: .trash(id: "m1")))) + } + + func test_givenTrashFails_whenTrashing_thenRestoresSnapshotAndSurfacesError() async { + let (vm, service, _) = makeViewModel() + vm.seedForTest(messages: [ + inbound("m1", from: "user-ada", username: "ada", at: 200), + inbound("m2", from: "user-bob", username: "bob", at: 100) + ]) + await service.enqueueTrash(failure: TestError.upstream("boom")) + + await vm.trash(messageID: "m1") + + XCTAssertEqual(vm.conversations.count, 2, "The optimistic removal was rolled back") + XCTAssertEqual(vm.error as? TestError, .upstream("boom")) + } + + func test_givenRestoreFails_whenRestoring_thenRestoresSnapshotAndSurfacesError() async { + let (vm, service, _) = makeViewModel() + vm.folder = .deleted + vm.seedForTest(messages: [inbound("m1", from: "user-ada", username: "ada", at: 100)]) + await service.enqueueRestore(failure: TestError.upstream("nope")) + + await vm.restore(messageID: "m1") + + XCTAssertEqual(vm.conversations.count, 1, "The optimistic removal was rolled back") + XCTAssertEqual(vm.error as? TestError, .upstream("nope")) + } + + // MARK: - Unread count → bus + + func test_givenUnreadCount_whenRefreshing_thenPublishesOnBus() async { + let (vm, service, bus) = makeViewModel() + await service.enqueueUnreadCount(success: 4) + + // Subscribe before the refresh so the event is captured. + let received = expectation(description: "unread event") + let task = Task { + for await event in bus.events() { + if case .unreadCountChanged(let count) = event { + XCTAssertEqual(count, 4) + received.fulfill() + return + } + } + } + // Give the subscription a beat to register. + try? await Task.sleep(nanoseconds: 10_000_000) + + await vm.refreshUnreadCount() + + await fulfillment(of: [received], timeout: 1.0) + task.cancel() + XCTAssertEqual(vm.unreadCount, 4) + } +} diff --git a/AppTests/DirectMessagesUnreadBadgeCoordinatorTests.swift b/AppTests/DirectMessagesUnreadBadgeCoordinatorTests.swift new file mode 100644 index 0000000..37bc894 --- /dev/null +++ b/AppTests/DirectMessagesUnreadBadgeCoordinatorTests.swift @@ -0,0 +1,104 @@ +// DirectMessagesUnreadBadgeCoordinatorTests +// +// BDD-named tests for the DM badge coordinator's pure fold logic and for +// the `UnreadBadgeAggregator` that sums the notifications + DM slots +// (the-gaps.md G1). The coordinator's stream plumbing is not exercised +// here (it's the same shape as the notifications coordinator, whose glue +// is already covered); we test the fold and the aggregation math, which +// is where the "don't regress the notifications badge" contract lives. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class DirectMessagesUnreadBadgeCoordinatorTests: XCTestCase { + + private func makeCoordinator() -> DirectMessagesUnreadBadgeCoordinator { + DirectMessagesUnreadBadgeCoordinator( + bus: DirectMessagesEventBus(), + reportCount: { _ in } + ) + } + + // MARK: - fold + + func test_givenUnreadCountChanged_whenFolding_thenReturnsThatCount() async { + let coordinator = makeCoordinator() + + let count = await coordinator.fold(event: .unreadCountChanged(5)) + + XCTAssertEqual(count, 5) + } + + func test_givenNegativeUnreadCount_whenFolding_thenClampsToZero() async { + let coordinator = makeCoordinator() + + let count = await coordinator.fold(event: .unreadCountChanged(-3)) + + XCTAssertEqual(count, 0) + } + + func test_givenThreadRead_whenFolding_thenReturnsLastKnownCount() async { + let coordinator = makeCoordinator() + _ = await coordinator.fold(event: .unreadCountChanged(4)) + + let count = await coordinator.fold(event: .threadRead(username: "ada")) + + XCTAssertEqual(count, 4, "threadRead holds the last-known total until the authoritative count arrives") + } + + func test_givenMessageSent_whenFolding_thenDoesNotChangeOwnUnread() async { + let coordinator = makeCoordinator() + _ = await coordinator.fold(event: .unreadCountChanged(2)) + + let count = await coordinator.fold(event: fixtureSentEvent()) + + XCTAssertEqual(count, 2, "Sending a message never changes your own unread count") + } + + // MARK: - Aggregator (non-regression: sum both sources) + + func test_givenBothSources_whenUpdated_thenBadgeShowsSum() async { + var written: [Int] = [] + let aggregator = UnreadBadgeAggregator(writeBadge: { written.append($0) }) + + aggregator.update(source: .notifications, count: 3) + aggregator.update(source: .directMessages, count: 2) + + XCTAssertEqual(aggregator.total, 5, "The dock badge sums both unread sources") + XCTAssertEqual(written.last, 5) + } + + func test_givenDMUpdate_whenNotificationsUnchanged_thenNotificationsContributionSurvives() async { + var written: [Int] = [] + let aggregator = UnreadBadgeAggregator(writeBadge: { written.append($0) }) + aggregator.update(source: .notifications, count: 7) + + // A DM update must not clobber the notifications contribution. + aggregator.update(source: .directMessages, count: 1) + + XCTAssertEqual(aggregator.total, 8) + XCTAssertEqual(written.last, 8, "DMs add to, never replace, the notifications badge") + } + + func test_givenNegativeSourceCount_whenUpdated_thenClampsToZero() async { + var written: [Int] = [] + let aggregator = UnreadBadgeAggregator(writeBadge: { written.append($0) }) + + aggregator.update(source: .directMessages, count: -4) + + XCTAssertEqual(aggregator.total, 0) + XCTAssertEqual(written.last, 0) + } + + // MARK: - Fixture + + private func fixtureSentEvent() -> DirectMessagesEvent { + let dm = DirectMessage( + id: "m1", senderId: "user-me", recipientId: "user-ada", body: "hi", + createdAt: Date(timeIntervalSince1970: 1_000) + ) + return .messageSent(recipientUsername: "ada", message: dm) + } +} diff --git a/AppTests/DocumentsListViewModelTests.swift b/AppTests/DocumentsListViewModelTests.swift index 6246dcd..990dc13 100644 --- a/AppTests/DocumentsListViewModelTests.swift +++ b/AppTests/DocumentsListViewModelTests.swift @@ -118,6 +118,116 @@ final class DocumentsListViewModelTests: XCTestCase { XCTAssertTrue(viewModel.documentsLoaded.isEmpty) } + // MARK: - createDocument(from template:) + + func test_givenNamedTemplate_whenCreatingFromTemplate_thenSeedsTitleAndBody() async { + // Happy path: the template's name becomes the title and its Markdown + // becomes the body on the create call. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let template = DocumentTemplate.meetingNotes + let created = DocumentsFixtures.document( + id: "D1", + title: template.name, + body: template.bodyMarkdown + ) + await stub.enqueueCreate(success: created) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: template) + + XCTAssertEqual(result?.id, "D1") + XCTAssertEqual(viewModel.documentsLoaded.first?.id, "D1") + XCTAssertEqual(viewModel.selectedDocumentID, "D1") + XCTAssertNil(viewModel.error) + + let recorded = await stub.recorded + let createCall = recorded.compactMap { call -> (String, String)? in + if case let .create(title, body, _, _) = call.kind { return (title, body) } + return nil + }.first + XCTAssertEqual(createCall?.0, template.name) + XCTAssertEqual(createCall?.1, template.bodyMarkdown) + } + + func test_givenBlankTemplate_whenCreatingFromTemplate_thenSeedsEmptyBody() async { + // Boundary: the Blank template is the identity path — an empty body, + // exactly like today's "new blank document" action. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let created = DocumentsFixtures.document(id: "D1", title: "Blank", body: "") + await stub.enqueueCreate(success: created) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: .blank) + + XCTAssertEqual(result?.id, "D1") + let recorded = await stub.recorded + let createCall = recorded.compactMap { call -> (String, String)? in + if case let .create(title, body, _, _) = call.kind { return (title, body) } + return nil + }.first + XCTAssertEqual(createCall?.0, "Blank") + XCTAssertEqual(createCall?.1, "") + } + + func test_givenBlankTitleOverride_whenCreatingFromTemplate_thenRejectsBeforeService() async { + // Invalid input: an explicit whitespace title override is rejected up + // front (via the shared create guard) and no create call is made. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: .meetingNotes, title: " ") + + XCTAssertNil(result) + XCTAssertEqual(viewModel.error as? DocumentsUIError, .invalidDocumentTitle) + let recorded = await stub.recorded + let createCalls = recorded.filter { + if case .create = $0.kind { return true } else { return false } + } + XCTAssertTrue(createCalls.isEmpty) + } + + func test_givenAPIFailure_whenCreatingFromTemplate_thenSurfacesError() async { + // Upstream failure: the service throws and the error surfaces; the + // rendered list is untouched. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let failure = TestError.upstream("denied") + await stub.enqueueCreate(failure: failure) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: .dailyLog) + + XCTAssertNil(result) + XCTAssertEqual(viewModel.error as? TestError, failure) + XCTAssertTrue(viewModel.documentsLoaded.isEmpty) + } + + func test_givenExplicitTitle_whenCreatingFromTemplate_thenUsesOverrideNotTemplateName() async { + // The caller may override the default (template name) title. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let created = DocumentsFixtures.document(id: "D1", title: "Q3 Planning") + await stub.enqueueCreate(success: created) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + _ = await viewModel.createDocument(from: .meetingNotes, title: "Q3 Planning") + + let recorded = await stub.recorded + let createTitle = recorded.compactMap { call -> String? in + if case let .create(title, _, _, _) = call.kind { return title } + return nil + }.first + XCTAssertEqual(createTitle, "Q3 Planning") + } + // MARK: - deleteDocument func test_givenLoadedDocument_whenDeleting_thenRemovesAndClearsSelection() async { diff --git a/AppTests/ExportViewModelTests.swift b/AppTests/ExportViewModelTests.swift index 7cee739..2d8127d 100644 --- a/AppTests/ExportViewModelTests.swift +++ b/AppTests/ExportViewModelTests.swift @@ -15,10 +15,25 @@ final class ExportViewModelTests: XCTestCase { private func makeSUT() -> (ExportViewModel, StubExportsService) { let service = StubExportsService() - let vm = ExportViewModel(exportsService: service) + let vm = ExportViewModel(exportsService: service, lists: StubListsService()) return (vm, service) } + /// SUT variant that exposes the lists stub for the Markdown-export path. + private func makeMarkdownSUT() -> (ExportViewModel, StubListsService) { + let lists = StubListsService() + let vm = ExportViewModel(exportsService: StubExportsService(), lists: lists) + return (vm, lists) + } + + private func ownedList(id: String, title: String, schema: String?) -> OwnedList { + OwnedList(id: id, title: title, description: nil, schemaDescription: schema) + } + + private func row(_ id: String, _ fields: [String: ListCellValue]) -> ListRow { + ListRow(id: id, listID: nil, fields: fields) + } + // MARK: - Happy path — each export type reaches the service and sets pendingExport func test_givenService_whenExportMessages_thenPendingExportSet() async throws { @@ -144,4 +159,77 @@ final class ExportViewModelTests: XCTestCase { XCTAssertNil(vm.errorMessage) XCTAssertNotNil(vm.pendingExport) } + + // MARK: - Markdown export (feature-gaps.md §1.3) + + func test_givenOwnedListsWithRows_whenExportListsAsMarkdown_thenRendersTable() async throws { + // Given one owned list with one row (no further pages). + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(success: .init(lists: [ownedList(id: "L1", title: "Films", schema: "Title:text, Year:number")], hasMore: false, nextOffset: nil)) + await lists.enqueueRows(success: .init(rows: [row("r1", ["Title": .string("Dune"), "Year": .int(1965)])], hasMore: false, nextOffset: nil)) + + // When + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 100_000_000) + + // Then — a Markdown document with the list heading and a schema-ordered table row. + let export = try XCTUnwrap(vm.pendingMarkdownExport) + XCTAssertEqual(export.filename, "interlinedlist-lists") + XCTAssertTrue(export.text.contains("# Films"), export.text) + XCTAssertTrue(export.text.contains("| Title | Year |")) + XCTAssertTrue(export.text.contains("| Dune | 1965 |")) + XCTAssertNil(vm.errorMessage) + XCTAssertFalse(vm.isExporting) + } + + func test_givenNoOwnedLists_whenExportListsAsMarkdown_thenPendingSetWithEmptyText() async throws { + // Given — boundary: the account owns no lists. + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(success: .empty) + + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 100_000_000) + + // Then — an empty document is still a valid export; no rows call was made. + let export = try XCTUnwrap(vm.pendingMarkdownExport) + XCTAssertTrue(export.text.isEmpty) + XCTAssertNil(vm.errorMessage) + let rowsCalls = await lists.recorded.contains { if case .rows = $0.kind { return true } else { return false } } + XCTAssertFalse(rowsCalls) + } + + func test_givenMyListsFailure_whenExportListsAsMarkdown_thenErrorSet() async throws { + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(failure: TestError.upstream("session expired")) + + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNil(vm.pendingMarkdownExport) + XCTAssertNotNil(vm.errorMessage) + XCTAssertFalse(vm.isExporting) + } + + func test_givenPaginatedListsAndRows_whenExportListsAsMarkdown_thenAllPagesFetched() async throws { + // Given two pages of lists, the first list itself spanning two row pages. + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(success: .init(lists: [ownedList(id: "L1", title: "A", schema: "K:text")], hasMore: true, nextOffset: 1)) + await lists.enqueueRows(success: .init(rows: [row("r1", ["K": .string("one")])], hasMore: true, nextOffset: 1)) + await lists.enqueueRows(success: .init(rows: [row("r2", ["K": .string("two")])], hasMore: false, nextOffset: nil)) + await lists.enqueueMyLists(success: .init(lists: [ownedList(id: "L2", title: "B", schema: "K:text")], hasMore: false, nextOffset: nil)) + await lists.enqueueRows(success: .init(rows: [row("r3", ["K": .string("three")])], hasMore: false, nextOffset: nil)) + + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 150_000_000) + + // Then — both lists and all rows appear; two myLists calls were made. + let export = try XCTUnwrap(vm.pendingMarkdownExport) + XCTAssertTrue(export.text.contains("# A")) + XCTAssertTrue(export.text.contains("# B")) + for value in ["one", "two", "three"] { + XCTAssertTrue(export.text.contains("| \(value) |"), "missing row \(value): \(export.text)") + } + let myListsCalls = await lists.recorded.filter { if case .myLists = $0.kind { return true } else { return false } } + XCTAssertEqual(myListsCalls.count, 2) + } } diff --git a/AppTests/LinkPreviewRenderingTests.swift b/AppTests/LinkPreviewRenderingTests.swift new file mode 100644 index 0000000..7d300a8 --- /dev/null +++ b/AppTests/LinkPreviewRenderingTests.swift @@ -0,0 +1,79 @@ +// LinkPreviewRenderingTests +// +// App-layer tests for the timeline link-preview rendering contract +// (feature-gaps §1.5). Per the project's view-layer rule we do NOT +// render `MessageRowView` / `LinkPreviewCardView` in XCTest — SwiftUI +// rendering is verified by the build and by hand. What we CAN pin here +// is the pure decision the row delegates to: which of a message's +// `linkPreviews` are "worth showing" (`LinkPreview.isRenderable`). This +// is the exact predicate `MessageRowView.renderablePreviews` filters on, +// so these tests guard the visible behaviour without touching a view. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +final class LinkPreviewRenderingTests: XCTestCase { + + private let base = URL(string: "https://example.com")! + + // Happy path: a fully-resolved preview is selected for rendering. + func test_givenMessageWithResolvedPreview_whenFilteringRenderable_thenPreviewIsIncluded() { + // Given + let resolved = LinkPreview( + url: base, + fetchStatus: "ready", + title: "Hello", + imageURL: URL(string: "https://cdn.example.com/i.png") + ) + let message = MessageFixtures.message(id: "m1", linkPreviews: [resolved]) + + // When + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertEqual(renderable.map(\.url), [base]) + } + + // Invalid/degraded input: a bare-URL preview is filtered out, so the row + // renders no card for it. + func test_givenMessageWithBareURLPreview_whenFilteringRenderable_thenPreviewIsExcluded() { + // Given — no title, no image, unresolved fetch status. + let bare = LinkPreview(url: base, fetchStatus: "pending") + let message = MessageFixtures.message(id: "m1", linkPreviews: [bare]) + + // When + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertTrue(renderable.isEmpty) + } + + // Mixed list: only the renderable entries survive, preserving order. + func test_givenMessageWithMixedPreviews_whenFilteringRenderable_thenOnlyRenderableSurviveInOrder() { + // Given + let good = LinkPreview(url: URL(string: "https://a.example.com")!, title: "A") + let bare = LinkPreview(url: URL(string: "https://b.example.com")!) + let alsoGood = LinkPreview(url: URL(string: "https://c.example.com")!, fetchStatus: "ok") + let message = MessageFixtures.message( + id: "m1", + linkPreviews: [good, bare, alsoGood] + ) + + // When + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertEqual(renderable.map(\.url.host), ["a.example.com", "c.example.com"]) + } + + // Empty / boundary: a message with no previews yields nothing to render. + func test_givenMessageWithNoPreviews_whenFilteringRenderable_thenResultIsEmpty() { + // Given / When + let message = MessageFixtures.message(id: "m1") + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertTrue(renderable.isEmpty) + } +} diff --git a/AppTests/ListFoldersViewModelTests.swift b/AppTests/ListFoldersViewModelTests.swift new file mode 100644 index 0000000..a2bc7cf --- /dev/null +++ b/AppTests/ListFoldersViewModelTests.swift @@ -0,0 +1,174 @@ +// ListFoldersViewModelTests +// +// BDD-named tests for the list-folders view model (the-gaps.md G6). +// Covers the required quartet plus the subscriber-gate and the delete +// rollback: +// - happy: load populates the tree. +// - invalid input: create with a name the service rejects +// (`invalidName`) surfaces the error, not the upsell. +// - upstream failure: load failure surfaces the error + hasLoadedOnce. +// - empty / boundary: load with an empty tree reports empty + +// hasLoadedOnce. +// - create happy: create + tree refetch replaces the tree. +// - subscriber gate: a `subscriberRequired` create raises the upsell, +// not an error. +// - delete happy: optimistic prune + service call. +// - delete rollback: failure restores the pruned subtree. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ListFoldersViewModelTests: XCTestCase { + + // MARK: - Helpers + + private func makeViewModel() -> (ListFoldersViewModel, StubListFoldersService) { + let service = StubListFoldersService() + let vm = ListFoldersViewModel(service: service) + return (vm, service) + } + + private func folder(_ id: String, name: String? = nil, parentId: String? = nil) -> ListFolder { + ListFolder(id: id, name: name ?? "Folder \(id)", parentId: parentId, createdAt: nil, updatedAt: nil) + } + + private func node(_ id: String, children: [ListFolderNode] = []) -> ListFolderNode { + ListFolderNode(folder: folder(id), children: children) + } + + // MARK: - load + + func test_givenFolders_whenLoading_thenTreePopulates() async { + let (vm, service) = makeViewModel() + await service.enqueueTree(success: [node("f1"), node("f2", children: [node("f3")])]) + + await vm.load() + + XCTAssertEqual(vm.tree.map(\.id), ["f1", "f2"]) + XCTAssertEqual(vm.tree.last?.children.map(\.id), ["f3"]) + XCTAssertNil(vm.error) + XCTAssertTrue(vm.hasLoadedOnce) + } + + func test_givenEmptyTree_whenLoading_thenReportsEmptyAndHasLoadedOnce() async { + let (vm, service) = makeViewModel() + await service.enqueueTree(success: []) + + await vm.load() + + XCTAssertTrue(vm.tree.isEmpty) + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertNil(vm.error) + } + + func test_givenTreeFailure_whenLoading_thenSurfacesErrorAndHasLoadedOnce() async { + let (vm, service) = makeViewModel() + await service.enqueueTree(failure: TestError.upstream("net")) + + await vm.load() + + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + XCTAssertTrue(vm.hasLoadedOnce) + } + + // MARK: - create + + func test_givenSubscriber_whenCreating_thenTreeRefetchedAndReplaced() async { + let (vm, service) = makeViewModel() + vm.seedForTest(tree: [node("f1")]) + await service.enqueueCreate(success: folder("f2", name: "New")) + // The post-create tree refetch returns the updated tree. + await service.enqueueTree(success: [node("f1"), node("f2")]) + + let created = await vm.create(name: "New", parentId: nil) + + XCTAssertEqual(created?.id, "f2") + XCTAssertEqual(vm.tree.map(\.id), ["f1", "f2"]) + XCTAssertFalse(vm.showSubscriberUpsell) + XCTAssertNil(vm.error) + } + + func test_givenNonSubscriber_whenCreating_thenRaisesUpsellNotError() async { + let (vm, service) = makeViewModel() + vm.seedForTest(tree: [node("f1")]) + await service.enqueueCreate(failure: ListFoldersError.subscriberRequired) + + let created = await vm.create(name: "New", parentId: nil) + + XCTAssertNil(created) + XCTAssertTrue(vm.showSubscriberUpsell) + XCTAssertNil(vm.error, "The subscriber gate must not surface as a raw error") + // The tree is untouched (no refetch on the gated path). + XCTAssertEqual(vm.tree.map(\.id), ["f1"]) + } + + func test_givenInvalidName_whenCreating_thenSurfacesErrorNotUpsell() async { + let (vm, service) = makeViewModel() + vm.seedForTest(tree: []) + await service.enqueueCreate(failure: ListFoldersError.invalidName) + + let created = await vm.create(name: "", parentId: nil) + + XCTAssertNil(created) + XCTAssertFalse(vm.showSubscriberUpsell) + XCTAssertEqual(vm.error as? ListFoldersError, .invalidName) + } + + // MARK: - delete + + func test_givenFolder_whenDeleting_thenPrunesSubtreeAndCallsService() async { + let (vm, service) = makeViewModel() + vm.seedForTest(tree: [node("f1"), node("f2", children: [node("f3")])]) + await service.enqueueDeleteSuccess() + + await vm.delete(id: "f2") + + XCTAssertEqual(vm.tree.map(\.id), ["f1"], "Deleting f2 removes it and its child f3") + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertTrue(recorded.contains { if case .delete(let id) = $0.kind { return id == "f2" } else { return false } }) + } + + func test_givenDeleteFailure_whenDeleting_thenRestoresSubtreeAndSurfacesError() async { + let (vm, service) = makeViewModel() + let initial = [node("f1"), node("f2", children: [node("f3")])] + vm.seedForTest(tree: initial) + await service.enqueueDelete(failure: TestError.upstream("net")) + + await vm.delete(id: "f2") + + XCTAssertEqual(vm.tree.map(\.id), ["f1", "f2"], "Failed delete must restore the pruned subtree") + XCTAssertEqual(vm.tree.last?.children.map(\.id), ["f3"]) + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + } + + // MARK: - rename / move + + func test_givenFolder_whenRenaming_thenTreeRefetched() async { + let (vm, service) = makeViewModel() + vm.seedForTest(tree: [node("f1")]) + await service.enqueueRename(success: folder("f1", name: "Renamed")) + await service.enqueueTree(success: [node("f1")]) + + await vm.rename(id: "f1", to: "Renamed") + + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertTrue(recorded.contains { if case .rename(let id, let name) = $0.kind { return id == "f1" && name == "Renamed" } else { return false } }) + } + + func test_givenFolder_whenMoving_thenTreeRefetched() async { + let (vm, service) = makeViewModel() + vm.seedForTest(tree: [node("f1"), node("f2")]) + await service.enqueueMove(success: folder("f2", parentId: "f1")) + await service.enqueueTree(success: [node("f1", children: [node("f2")])]) + + await vm.move(id: "f2", toParent: "f1") + + XCTAssertNil(vm.error) + XCTAssertEqual(vm.tree.map(\.id), ["f1"]) + XCTAssertEqual(vm.tree.first?.children.map(\.id), ["f2"]) + } +} diff --git a/AppTests/ListRowsViewModelTests.swift b/AppTests/ListRowsViewModelTests.swift index 60aab76..f5f230e 100644 --- a/AppTests/ListRowsViewModelTests.swift +++ b/AppTests/ListRowsViewModelTests.swift @@ -152,6 +152,22 @@ final class ListRowsViewModelTests: XCTestCase { XCTAssertEqual(ListRowsViewModel.parse("0", as: .boolean), .bool(false)) } + func test_givenSelectOption_whenParsingAsSelect_thenReturnsString() { + // §1.1 — a select cell stores the chosen option's raw text. + XCTAssertEqual(ListRowsViewModel.parse("high", as: .select), .string("high")) + } + + func test_givenMarkdownSource_whenParsingAsMarkdown_thenReturnsString() { + // §1.1 — a markdown cell stores raw Markdown source verbatim. + XCTAssertEqual(ListRowsViewModel.parse("# Title", as: .markdown), .string("# Title")) + } + + func test_givenWhitespace_whenParsingAsSelectOrMarkdown_thenReturnsNull() { + // Boundary — a cleared select/markdown cell projects to null. + XCTAssertEqual(ListRowsViewModel.parse(" ", as: .select), .null) + XCTAssertEqual(ListRowsViewModel.parse("", as: .markdown), .null) + } + // MARK: - apply(event:) func test_givenRowEventForOtherList_whenApplied_thenIsNoop() async { diff --git a/AppTests/ModerationActionViewModelTests.swift b/AppTests/ModerationActionViewModelTests.swift new file mode 100644 index 0000000..f526326 --- /dev/null +++ b/AppTests/ModerationActionViewModelTests.swift @@ -0,0 +1,137 @@ +// ModerationActionViewModelTests +// +// BDD-named tests for the reusable moderation-action view model +// (the-gaps.md G2) that backs `ModerationMenu` + `ReportReasonSheet`. +// Covers the required quartet across the block / report surfaces: +// - happy: block flips isBlocked + calls the service. +// - invalid input: reportMessage on a profile subject (no messageID) +// is rejected before any service call. +// - upstream failure: a failing block surfaces the error and leaves +// isBlocked false. +// - empty / boundary: reportUser with an empty detail still submits +// (empty detail is a valid "no detail" case; the domain normalizes +// it to nil). +// - report happy: reportMessage on a message subject submits + sets +// didSubmitReport, forwarding the chosen reason. +// - mute happy + failure symmetry. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ModerationActionViewModelTests: XCTestCase { + + private func makeUserSubject() -> (ModerationActionViewModel, StubModerationService) { + let service = StubModerationService() + let vm = ModerationActionViewModel(username: "alice", messageID: nil, service: service) + return (vm, service) + } + + private func makeMessageSubject() -> (ModerationActionViewModel, StubModerationService) { + let service = StubModerationService() + let vm = ModerationActionViewModel(username: "alice", messageID: "m1", service: service) + return (vm, service) + } + + // MARK: - block + + func test_givenUserSubject_whenBlocking_thenFlipsBlockedAndCallsService() async { + let (vm, service) = makeUserSubject() + await service.enqueueBlockSuccess() + + await vm.block() + + XCTAssertTrue(vm.isBlocked) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertTrue(recorded.contains { if case .block(let u) = $0.kind { return u == "alice" } else { return false } }) + } + + func test_givenBlockFailure_whenBlocking_thenSurfacesErrorAndStaysUnblocked() async { + let (vm, service) = makeUserSubject() + await service.enqueueBlock(failure: TestError.upstream("net")) + + await vm.block() + + XCTAssertFalse(vm.isBlocked) + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + } + + // MARK: - mute + + func test_givenUserSubject_whenMuting_thenFlipsMutedAndCallsService() async { + let (vm, service) = makeUserSubject() + await service.enqueueMuteSuccess() + + await vm.mute() + + XCTAssertTrue(vm.isMuted) + XCTAssertNil(vm.error) + } + + // MARK: - reportMessage invalid input + + func test_givenProfileSubject_whenReportingMessage_thenRejectedBeforeServiceCall() async { + let (vm, service) = makeUserSubject() // no messageID + + await vm.reportMessage(reason: .spam, detail: nil) + + XCTAssertEqual(vm.error as? ModerationActionError, .noMessageSubject) + XCTAssertFalse(vm.didSubmitReport) + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "A message report with no message subject must not hit the service") + } + + func test_givenProfileSubject_whenReportingMessage_thenCanReportMessageIsFalse() async { + let (vm, _) = makeUserSubject() + XCTAssertFalse(vm.canReportMessage) + } + + // MARK: - reportMessage happy + + func test_givenMessageSubject_whenReportingMessage_thenSubmitsWithReason() async { + let (vm, service) = makeMessageSubject() + await service.enqueueReportMessageSuccess() + + await vm.reportMessage(reason: .harassment, detail: "abusive") + + XCTAssertTrue(vm.didSubmitReport) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertTrue(recorded.contains { + if case .reportMessage(let id, let reason, let detail) = $0.kind { + return id == "m1" && reason == .harassment && detail == "abusive" + } + return false + }) + } + + // MARK: - reportUser boundary (empty detail) + + func test_givenUserSubject_whenReportingWithNilDetail_thenSubmitsSuccessfully() async { + let (vm, service) = makeUserSubject() + await service.enqueueReportUserSuccess() + + await vm.reportUser(reason: .other, detail: nil) + + XCTAssertTrue(vm.didSubmitReport) + let recorded = await service.recorded + XCTAssertTrue(recorded.contains { + if case .reportUser(let u, let reason, let detail) = $0.kind { + return u == "alice" && reason == .other && detail == nil + } + return false + }) + } + + func test_givenReportUserFailure_whenReporting_thenSurfacesErrorAndDidNotSubmit() async { + let (vm, service) = makeUserSubject() + await service.enqueueReportUser(failure: TestError.upstream("net")) + + await vm.reportUser(reason: .spam, detail: nil) + + XCTAssertFalse(vm.didSubmitReport) + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + } +} diff --git a/AppTests/NewMessageViewModelTests.swift b/AppTests/NewMessageViewModelTests.swift new file mode 100644 index 0000000..cfbf588 --- /dev/null +++ b/AppTests/NewMessageViewModelTests.swift @@ -0,0 +1,137 @@ +// NewMessageViewModelTests +// +// BDD-named tests for the new-message composer view model (the-gaps.md +// G1). Covers the required quartet: +// - happy: a picked recipient + non-blank body sends and reports the +// sent message. +// - invalid input: send with no recipient / blank body never calls the +// service. +// - upstream failure: a failing send surfaces the error. +// - empty / boundary: an empty recipient list is reported so the sheet +// can show its "no mutual followers" state. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class NewMessageViewModelTests: XCTestCase { + + private func makeViewModel() -> (NewMessageViewModel, StubDirectMessagesService) { + let service = StubDirectMessagesService() + let vm = NewMessageViewModel(service: service) + return (vm, service) + } + + private func user(_ id: String, _ username: String) -> UserSummary { + UserSummary(id: id, username: username, displayName: username.capitalized, avatarURL: nil) + } + + private func sent(_ id: String, to recipient: String) -> DirectMessage { + DirectMessage( + id: id, senderId: "user-me", recipientId: recipient, body: "hi", + createdAt: Date(timeIntervalSince1970: 1_000), readAt: nil, + sender: nil, recipient: nil + ) + } + + // MARK: - Recipient load + + func test_givenRecipients_whenLoading_thenPopulatesList() async { + let (vm, service) = makeViewModel() + await service.enqueueRecipients(success: [user("u1", "ada"), user("u2", "bob")]) + + await vm.loadRecipients() + + XCTAssertEqual(vm.recipients.map(\.username), ["ada", "bob"]) + XCTAssertTrue(vm.hasLoadedRecipients) + } + + func test_givenPreselectUsername_whenLoading_thenAutoSelectsMatch() async { + let (vm, service) = makeViewModel() + await service.enqueueRecipients(success: [user("u1", "ada"), user("u2", "bob")]) + + await vm.loadRecipients(preselectUsername: "bob") + + XCTAssertEqual(vm.selectedRecipientId, "u2") + } + + // MARK: - Happy path + + func test_givenRecipientAndBody_whenSending_thenReportsSentMessage() async { + let (vm, service) = makeViewModel() + await service.enqueueRecipients(success: [user("u1", "ada")]) + await service.enqueueSend(success: sent("s1", to: "u1")) + await vm.loadRecipients() + vm.selectedRecipientId = "u1" + vm.body = "hi" + + await vm.send() + + XCTAssertEqual(vm.sentMessage?.id, "s1") + XCTAssertNil(vm.error) + XCTAssertEqual(vm.selectedRecipientUsername, "ada") + } + + // MARK: - Invalid input + + func test_givenNoRecipient_whenSending_thenRejectedWithoutServiceCall() async { + let (vm, service) = makeViewModel() + vm.body = "hi" + + await vm.send() + + XCTAssertEqual(vm.error as? NewMessageError, .noRecipient) + let recorded = await service.recorded + XCTAssertFalse(recorded.contains(where: { if case .send = $0.kind { return true } else { return false } })) + } + + func test_givenBlankBody_whenSending_thenRejectedWithoutServiceCall() async { + let (vm, service) = makeViewModel() + vm.selectedRecipientId = "u1" + vm.body = " " + + await vm.send() + + XCTAssertEqual(vm.error as? NewMessageError, .emptyBody) + let recorded = await service.recorded + XCTAssertFalse(recorded.contains(where: { if case .send = $0.kind { return true } else { return false } })) + } + + // MARK: - Upstream failure + + func test_givenSendFails_whenSending_thenSurfacesError() async { + let (vm, service) = makeViewModel() + await service.enqueueSend(failure: TestError.upstream("boom")) + vm.selectedRecipientId = "u1" + vm.body = "hi" + + await vm.send() + + XCTAssertEqual(vm.error as? TestError, .upstream("boom")) + XCTAssertNil(vm.sentMessage) + } + + // MARK: - Empty / boundary + + func test_givenNoMutualFollowers_whenLoading_thenReportsEmptyList() async { + let (vm, service) = makeViewModel() + await service.enqueueRecipients(success: []) + + await vm.loadRecipients() + + XCTAssertTrue(vm.recipients.isEmpty) + XCTAssertTrue(vm.hasLoadedRecipients) + XCTAssertFalse(vm.canSend) + } + + func test_givenRecipientsFail_whenLoading_thenSurfacesError() async { + let (vm, service) = makeViewModel() + await service.enqueueRecipients(failure: TestError.upstream("net")) + + await vm.loadRecipients() + + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + XCTAssertTrue(vm.hasLoadedRecipients) + } +} diff --git a/AppTests/ProfileMessageButtonViewModelTests.swift b/AppTests/ProfileMessageButtonViewModelTests.swift new file mode 100644 index 0000000..2ec7500 --- /dev/null +++ b/AppTests/ProfileMessageButtonViewModelTests.swift @@ -0,0 +1,97 @@ +// ProfileMessageButtonViewModelTests +// +// BDD-named tests for the profile "Message" button gate (the-gaps.md G1). +// Covers the ownership-gating rule and eligibility: +// - happy: an eligible (mutual recipient) profile shows the button. +// - invalid input (self): messaging yourself is hidden without a call. +// - ownership gate: an unresolved session hides the button (nil signal) +// without a service call. +// - upstream failure: a failing eligibility check fails closed (hidden). +// - empty / boundary: a non-recipient profile hides the button. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ProfileMessageButtonViewModelTests: XCTestCase { + + private func makeViewModel( + username: String, + currentUsername: String? + ) -> (ProfileMessageButtonViewModel, StubDirectMessagesService) { + let service = StubDirectMessagesService() + let vm = ProfileMessageButtonViewModel( + username: username, + service: service, + currentUsername: { currentUsername } + ) + return (vm, service) + } + + private func user(_ id: String, _ username: String) -> UserSummary { + UserSummary(id: id, username: username, displayName: username.capitalized, avatarURL: nil) + } + + // MARK: - Happy path (eligible recipient shows the button) + + func test_givenMutualRecipient_whenChecking_thenButtonShows() async { + let (vm, service) = makeViewModel(username: "ada", currentUsername: "me") + await service.enqueueRecipients(success: [user("u1", "ada"), user("u2", "bob")]) + + await vm.check() + + XCTAssertEqual(vm.isEligible, true) + XCTAssertTrue(vm.shouldShow) + } + + // MARK: - Invalid input (self-profile) + + func test_givenSelfProfile_whenChecking_thenHiddenWithoutServiceCall() async { + let (vm, service) = makeViewModel(username: "me", currentUsername: "me") + + await vm.check() + + XCTAssertEqual(vm.isEligible, false) + XCTAssertFalse(vm.shouldShow) + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "Self-profile never checks recipients") + } + + // MARK: - Ownership gate (unresolved session) + + func test_givenUnresolvedSession_whenChecking_thenHiddenWithoutServiceCall() async { + let (vm, service) = makeViewModel(username: "ada", currentUsername: nil) + + await vm.check() + + XCTAssertNil(vm.isEligible, "Nil current user is the hidden/undetermined signal") + XCTAssertFalse(vm.shouldShow) + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "An unresolved session never touches the network") + } + + // MARK: - Upstream failure (fail closed) + + func test_givenRecipientsFail_whenChecking_thenFailsClosedHidden() async { + let (vm, service) = makeViewModel(username: "ada", currentUsername: "me") + await service.enqueueRecipients(failure: TestError.upstream("net")) + + await vm.check() + + XCTAssertEqual(vm.isEligible, false, "A broken check fails closed") + XCTAssertFalse(vm.shouldShow) + } + + // MARK: - Empty / boundary (non-recipient) + + func test_givenNonRecipient_whenChecking_thenHidden() async { + let (vm, service) = makeViewModel(username: "carol", currentUsername: "me") + await service.enqueueRecipients(success: [user("u1", "ada")]) + + await vm.check() + + XCTAssertEqual(vm.isEligible, false) + XCTAssertFalse(vm.shouldShow) + } +} diff --git a/AppTests/ResolveShareViewModelTests.swift b/AppTests/ResolveShareViewModelTests.swift new file mode 100644 index 0000000..5002e91 --- /dev/null +++ b/AppTests/ResolveShareViewModelTests.swift @@ -0,0 +1,197 @@ +// ResolveShareViewModelTests +// +// BDD-named tests for the shared-resource landing view model (the-gaps.md +// G3). Covers the required quartet plus the ownership-gating rule: +// - happy: resolve populates the resource + role; claim (signed in, +// claimable) records the authoritative `ShareClaim`. +// - invalid input: `claimAccess()` on a non-claimable / signed-out share +// is a guarded no-op — asserts the claim service was never called. +// - upstream failure: a failing resolve surfaces the error and leaves +// `resolved` nil; a failing claim surfaces the error without flipping +// `didClaim`. +// - empty / boundary: a resolve with no `resource` still populates role +// and reports a title-less preview. +// - ownership gating: a claimable share with a `nil` current user hides +// the claim button (`canOfferClaim == false`) and prompts sign-in +// (`needsSignIn == true`) — never enabled-but-broken. +// - target dispatch: list vs document resolves/claims hit the matching +// service methods. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ResolveShareViewModelTests: XCTestCase { + + // MARK: - Helpers + + private func makeViewModel( + parsed: ParsedShare = ParsedShare(kind: .list, token: "tok"), + currentUserID: String? = "U1" + ) -> (ResolveShareViewModel, StubSharingService) { + let service = StubSharingService() + let vm = ResolveShareViewModel(service: service, parsed: parsed, currentUserID: currentUserID) + return (vm, service) + } + + private func resolved( + role: ShareRole = .collaborator, + canClaim: Bool = true, + needsAuth: Bool = false, + resource: ResolvedShare.Resource? = .list(id: "L1", title: "Roadmap", description: nil, isPublic: false) + ) -> ResolvedShare { + ResolvedShare(role: role, canClaim: canClaim, needsAuth: needsAuth, resource: resource) + } + + // MARK: - Happy path + + func test_givenListToken_whenResolving_thenPublishesResourceAndRole() async { + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + + await vm.resolve() + + XCTAssertEqual(vm.resolved?.resource?.title, "Roadmap") + XCTAssertEqual(vm.resolved?.role, .collaborator) + XCTAssertTrue(vm.canOfferClaim, "Signed-in + claimable → claim offered") + XCTAssertFalse(vm.needsSignIn) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .resolveList(token: "tok"))]) + } + + func test_givenClaimableShare_whenClaiming_thenRecordsAuthoritativeClaim() async { + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + await service.enqueueClaim(success: ShareClaim(resourceId: "L1", role: .collaborator)) + await vm.resolve() + + await vm.claimAccess() + + XCTAssertTrue(vm.didClaim) + XCTAssertEqual(vm.claim?.resourceId, "L1") + XCTAssertEqual(vm.claim?.role, .collaborator) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertEqual(recorded.last, .init(kind: .claimList(token: "tok"))) + } + + // MARK: - Invalid input (guarded claim → no service call) + + func test_givenNonClaimableShare_whenClaiming_thenNoServiceCallAndNoClaim() async { + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved(canClaim: false)) + await vm.resolve() + + await vm.claimAccess() + + XCTAssertFalse(vm.didClaim) + XCTAssertNil(vm.claim) + // Only the resolve call was recorded — the guard skipped the claim. + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .resolveList(token: "tok"))]) + } + + // MARK: - Upstream API failure + + func test_givenUpstreamFailure_whenResolving_thenSurfacesErrorAndLeavesResolvedNil() async { + let (vm, service) = makeViewModel() + await service.enqueueResolve(failure: TestError.upstream("gone")) + + await vm.resolve() + + XCTAssertNil(vm.resolved) + XCTAssertEqual(vm.error as? TestError, .upstream("gone")) + } + + func test_givenClaimFails_whenClaiming_thenSurfacesErrorAndDidClaimStaysFalse() async { + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved()) + await service.enqueueClaim(failure: TestError.upstream("denied")) + await vm.resolve() + + await vm.claimAccess() + + XCTAssertFalse(vm.didClaim) + XCTAssertEqual(vm.error as? TestError, .upstream("denied")) + } + + // MARK: - Empty / boundary + + func test_givenResolveWithNoResource_whenResolving_thenRolePopulatedAndTitleNil() async { + let (vm, service) = makeViewModel() + await service.enqueueResolve(success: resolved(resource: nil)) + + await vm.resolve() + + XCTAssertNotNil(vm.resolved) + XCTAssertNil(vm.resolved?.resource) + XCTAssertEqual(vm.resolved?.role, .collaborator) + } + + // MARK: - Ownership gating (nil user hides claim, prompts sign-in) + + func test_givenClaimableShareButNoCurrentUser_whenResolving_thenClaimHiddenAndSignInPrompted() async { + let (vm, service) = makeViewModel(currentUserID: nil) + await service.enqueueResolve(success: resolved(canClaim: true)) + + await vm.resolve() + + XCTAssertFalse(vm.canOfferClaim, "Unknown user must hide the claim action") + XCTAssertTrue(vm.needsSignIn, "A claimable share with no user prompts sign-in") + } + + func test_givenNeedsAuthShare_whenResolving_thenSignInPrompted() async { + let (vm, service) = makeViewModel(currentUserID: "U1") + await service.enqueueResolve(success: resolved(canClaim: false, needsAuth: true)) + + await vm.resolve() + + XCTAssertTrue(vm.needsSignIn) + XCTAssertFalse(vm.canOfferClaim) + } + + func test_givenSignInResolvesLater_whenCurrentUserUpdated_thenClaimBecomesOfferable() async { + let (vm, service) = makeViewModel(currentUserID: nil) + await service.enqueueResolve(success: resolved(canClaim: true)) + await vm.resolve() + XCTAssertFalse(vm.canOfferClaim) + + vm.updateCurrentUser(id: "U2") + + XCTAssertTrue(vm.canOfferClaim, "Once a user resolves, a claimable share becomes offerable") + XCTAssertFalse(vm.needsSignIn) + } + + // MARK: - Target dispatch (documents half) + + func test_givenDocumentToken_whenResolving_thenCallsDocumentEndpoint() async { + let (vm, service) = makeViewModel(parsed: ParsedShare(kind: .document, token: "dtok")) + await service.enqueueResolve(success: resolved( + resource: .document(id: "D1", title: "Spec", isPublic: false) + )) + + await vm.resolve() + + XCTAssertEqual(vm.resolved?.resource?.title, "Spec") + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .resolveDocument(token: "dtok"))]) + } + + func test_givenDocumentToken_whenClaiming_thenCallsDocumentClaimEndpoint() async { + let (vm, service) = makeViewModel(parsed: ParsedShare(kind: .document, token: "dtok")) + await service.enqueueResolve(success: resolved( + resource: .document(id: "D1", title: "Spec", isPublic: false) + )) + await service.enqueueClaim(success: ShareClaim(resourceId: "D1", role: .manager)) + await vm.resolve() + + await vm.claimAccess() + + XCTAssertTrue(vm.didClaim) + XCTAssertEqual(vm.claim?.resourceId, "D1") + let recorded = await service.recorded + XCTAssertEqual(recorded.last, .init(kind: .claimDocument(token: "dtok"))) + } +} diff --git a/AppTests/SchemaEditorViewModelTests.swift b/AppTests/SchemaEditorViewModelTests.swift index 56d3436..81d4f7e 100644 --- a/AppTests/SchemaEditorViewModelTests.swift +++ b/AppTests/SchemaEditorViewModelTests.swift @@ -172,11 +172,149 @@ final class SchemaEditorViewModelTests: XCTestCase { XCTAssertFalse(viewModel.didFinish) } + // MARK: - select options (§1.1) + + func test_givenSelectFieldFromSchema_whenInitialising_thenSeedsOptions() { + // Happy path — a `select` column's options hydrate the editable row. + let viewModel = makeViewModel( + initial: ListSchema(fields: [ + SchemaField(name: "Priority", type: .select, enumValues: ["low", "high"]) + ]) + ) + + XCTAssertEqual(viewModel.fields.first?.type, .select) + XCTAssertEqual(viewModel.fields.first?.options, ["low", "high"]) + } + + func test_givenSelectField_whenAddingOption_thenAppendsBlankOption() { + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: ["a"])]) + ) + let id = viewModel.fields[0].id + + viewModel.addOption(toFieldID: id) + + XCTAssertEqual(viewModel.fields[0].options, ["a", ""]) + } + + func test_givenSelectField_whenSettingAndRemovingOptions_thenMutatesInPlace() { + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: ["a", "b", "c"])]) + ) + let id = viewModel.fields[0].id + + viewModel.setOption("z", forFieldID: id, at: 1) + viewModel.removeOption(fromFieldID: id, at: 0) + + XCTAssertEqual(viewModel.fields[0].options, ["z", "c"]) + } + + func test_givenSelectField_whenSwitchingTypeAway_thenDiscardsOptions() { + // Boundary — options must not survive a switch to a non-select type, + // or `save()` would carry a stale set into the DSL. + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: ["a", "b"])]) + ) + let id = viewModel.fields[0].id + + viewModel.setType(.text, forFieldID: id) + + XCTAssertTrue(viewModel.fields[0].options.isEmpty) + } + + func test_givenSelectWithNoOptions_whenValidating_thenReturnsError() { + // Invalid input — a select with an empty option set fails validation, + // mirroring the DSL parser's `.emptySelectOptions`. + let viewModel = makeViewModel(initial: .empty) + viewModel.addField() + let id = viewModel.fields[0].id + viewModel.fields[0].name = "P" + viewModel.setType(.select, forFieldID: id) + + XCTAssertNotNil(viewModel.validationError(for: viewModel.fields[0])) + XCTAssertFalse(viewModel.isValid) + } + + func test_givenSelectWithDuplicateOptions_whenValidating_thenReturnsError() { + // Invalid input — duplicate options fail validation, mirroring the + // parser's `.duplicateSelectOption`. + let viewModel = makeViewModel( + initial: ListSchema(fields: [ + SchemaField(name: "P", type: .select, enumValues: ["a", "a"]) + ]) + ) + + XCTAssertNotNil(viewModel.validationError(for: viewModel.fields[0])) + XCTAssertFalse(viewModel.isValid) + } + + func test_givenValidSelectSchema_whenSaving_thenRoundTripsOptionsToService() async { + // Happy path — a valid `select` column saves with its options intact. + let stub = StubListsService() + let saved = ListSchema(fields: [ + SchemaField(name: "Priority", type: .select, enumValues: ["low", "high"]) + ]) + await stub.enqueueUpdateSchema(success: saved) + let viewModel = SchemaEditorViewModel( + lists: stub, + eventBus: ListsEventBus(), + listId: "L1", + role: .owner, + initialSchema: saved + ) + + await viewModel.save() + + XCTAssertTrue(viewModel.didFinish) + let sent = await stub.lastUpdatedSchema + XCTAssertEqual(sent?.fields.first?.type, .select) + XCTAssertEqual(sent?.fields.first?.enumValues, ["low", "high"]) + } + + func test_givenInvalidSelectSchema_whenSaving_thenDoesNotCallService() async { + // Invalid input — a select with no options must not reach the service. + let stub = StubListsService() + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: [])]), + lists: stub + ) + + await viewModel.save() + + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + XCTAssertFalse(viewModel.didFinish) + } + + func test_givenMarkdownField_whenSaving_thenSerialisesWithNoOptions() async { + // Markdown is long-text: saves as a bare token, no options attached. + let stub = StubListsService() + let saved = ListSchema(fields: [SchemaField(name: "Body", type: .markdown)]) + await stub.enqueueUpdateSchema(success: saved) + let viewModel = SchemaEditorViewModel( + lists: stub, + eventBus: ListsEventBus(), + listId: "L1", + role: .owner, + initialSchema: saved + ) + + await viewModel.save() + + let sent = await stub.lastUpdatedSchema + XCTAssertEqual(sent?.fields.first?.type, .markdown) + XCTAssertNil(sent?.fields.first?.enumValues) + } + // MARK: - helpers - private func makeViewModel(initial: ListSchema, role: WatcherRole = .owner) -> SchemaEditorViewModel { + private func makeViewModel( + initial: ListSchema, + role: WatcherRole = .owner, + lists: StubListsService = StubListsService() + ) -> SchemaEditorViewModel { SchemaEditorViewModel( - lists: StubListsService(), + lists: lists, eventBus: ListsEventBus(), listId: "L1", role: role, diff --git a/AppTests/SearchViewModelTests.swift b/AppTests/SearchViewModelTests.swift new file mode 100644 index 0000000..6ce2a57 --- /dev/null +++ b/AppTests/SearchViewModelTests.swift @@ -0,0 +1,186 @@ +// SearchViewModelTests +// +// BDD-named tests for the global-search view model (the-gaps.md G5). +// Covers the required quartet plus the stale-response guard and clear: +// - happy: a non-blank query populates the three grouped sections. +// - invalid input: a blank / whitespace-only query never calls the +// service (asserted on the stub's recorded-call log). +// - upstream failure: a failing leg surfaces the error and clears +// results. +// - empty / boundary: a non-blank query with three empty legs sets +// `hasSearched` and reports `results.isEmpty`. +// - stale guard: a superseded slow response does not clobber a newer +// result. +// - clear: resets query / results / error / hasSearched. +// +// Tests drive the view model through `searchNow()` (bypasses the +// debounce and awaits) so assertions are deterministic. A dedicated case +// exercises the `query`-didSet debounce path with a `.zero` window. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class SearchViewModelTests: XCTestCase { + + // MARK: - Helpers + + private func makeViewModel() -> (SearchViewModel, StubSearchService) { + let service = StubSearchService() + let vm = SearchViewModel(service: service, debounce: .zero) + return (vm, service) + } + + private func message(_ id: String) -> Message { + MessageFixtures.message(id: id, text: "hit \(id)") + } + + private func list(_ id: String) -> ListSummary { + ListSummary( + id: id, + title: "List \(id)", + description: nil, + visibility: .public, + createdAt: nil, + updatedAt: nil + ) + } + + private func document(_ id: String) -> Document { + Document( + id: id, + folderId: nil, + title: "Doc \(id)", + body: DocumentBody(markdown: "body \(id)"), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + createdAt: nil, + isPublic: false, + deleted: false, + version: nil + ) + } + + // MARK: - Happy path + + func test_givenNonBlankQuery_whenSearching_thenGroupsHitsIntoSections() async { + let (vm, service) = makeViewModel() + await service.enqueueAll( + messages: [message("m1"), message("m2")], + lists: [list("l1")], + documents: [document("d1"), document("d2"), document("d3")] + ) + + vm.query = "swift" + await vm.searchNow() + + XCTAssertEqual(vm.results.messages.map(\.id), ["m1", "m2"]) + XCTAssertEqual(vm.results.lists.map(\.id), ["l1"]) + XCTAssertEqual(vm.results.documents.map(\.id), ["d1", "d2", "d3"]) + XCTAssertEqual(vm.results.totalCount, 6) + XCTAssertFalse(vm.results.isEmpty) + XCTAssertNil(vm.error) + XCTAssertTrue(vm.hasSearched) + XCTAssertFalse(vm.isSearching) + } + + // MARK: - Invalid input (blank query → no service call) + + func test_givenBlankQuery_whenSearching_thenServiceIsNotCalledAndResultsCleared() async { + let (vm, service) = makeViewModel() + + vm.query = " " + await vm.searchNow() + + let recorded = await service.recorded + XCTAssertTrue(recorded.isEmpty, "A blank query must not fan out any request") + XCTAssertTrue(vm.results.isEmpty) + XCTAssertFalse(vm.hasSearched) + XCTAssertNil(vm.error) + } + + func test_givenPopulatedResults_whenQueryBlanked_thenResultsClearedWithoutCall() async { + let (vm, service) = makeViewModel() + await service.enqueueAll(messages: [message("m1")], lists: [], documents: []) + vm.query = "swift" + await vm.searchNow() + XCTAssertFalse(vm.results.isEmpty) + + // Blanking the field clears synchronously in the didSet path. + vm.query = "" + + XCTAssertTrue(vm.results.isEmpty) + XCTAssertFalse(vm.hasSearched) + let recorded = await service.recorded + // Only the first (non-blank) search reached the service. + XCTAssertEqual(recorded.count, 3, "Blanking must not add another fan-out") + } + + // MARK: - Upstream API failure + + func test_givenUpstreamFailure_whenSearching_thenSurfacesErrorAndClearsResults() async { + let (vm, service) = makeViewModel() + // `all(query:)` throws if any leg fails; stage a messages failure. + await service.enqueueMessages(failure: TestError.upstream("net")) + await service.enqueueLists(success: []) + await service.enqueueDocuments(success: []) + + vm.query = "swift" + await vm.searchNow() + + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + XCTAssertTrue(vm.results.isEmpty) + XCTAssertTrue(vm.hasSearched) + XCTAssertFalse(vm.isSearching) + } + + // MARK: - Empty / boundary + + func test_givenNoHits_whenSearching_thenReportsEmptyAndHasSearched() async { + let (vm, service) = makeViewModel() + await service.enqueueAll(messages: [], lists: [], documents: []) + + vm.query = "nothing-here" + await vm.searchNow() + + XCTAssertTrue(vm.results.isEmpty) + XCTAssertEqual(vm.results.totalCount, 0) + XCTAssertTrue(vm.hasSearched) + XCTAssertNil(vm.error) + } + + // MARK: - Debounce path + + func test_givenZeroDebounce_whenQuerySet_thenDebouncedSearchResolves() async { + let (vm, service) = makeViewModel() + await service.enqueueAll(messages: [message("m1")], lists: [], documents: []) + + vm.query = "swift" + // Let the debounced task (zero window) run to completion. + await Task.yield() + for _ in 0..<10 where !vm.hasSearched { + try? await Task.sleep(nanoseconds: 5_000_000) + } + + XCTAssertEqual(vm.results.messages.map(\.id), ["m1"]) + XCTAssertTrue(vm.hasSearched) + } + + // MARK: - clear + + func test_givenActiveSearch_whenCleared_thenResetsAllState() async { + let (vm, service) = makeViewModel() + await service.enqueueAll(messages: [message("m1")], lists: [list("l1")], documents: []) + vm.query = "swift" + await vm.searchNow() + XCTAssertFalse(vm.results.isEmpty) + + vm.clear() + + XCTAssertEqual(vm.query, "") + XCTAssertTrue(vm.results.isEmpty) + XCTAssertNil(vm.error) + XCTAssertFalse(vm.hasSearched) + XCTAssertFalse(vm.isSearching) + } +} diff --git a/AppTests/ServerTemplatesViewModelTests.swift b/AppTests/ServerTemplatesViewModelTests.swift new file mode 100644 index 0000000..44fbd69 --- /dev/null +++ b/AppTests/ServerTemplatesViewModelTests.swift @@ -0,0 +1,224 @@ +// ServerTemplatesViewModelTests +// +// BDD-named view-model tests for the server document-templates section of the +// "New from Template…" picker (the-gaps.md G12). Stubbed +// `DocumentTemplatesServicing` + `DocumentsServicing`; no networking. +// +// The quartet per behavior: +// • load: happy / empty / non-fatal-failure / whitespace-path boundary +// • create-from-template: happy / debounce-no-op / upstream-failure / +// zero-new-docs boundary +// • seed defaults: happy / failure + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ServerTemplatesViewModelTests: XCTestCase { + + // MARK: - Helpers + + private func makeSUT( + documentsStub: StubDocumentsService = StubDocumentsService(), + templatesStub: StubDocumentTemplatesService = StubDocumentTemplatesService() + ) -> (ServerTemplatesViewModel, DocumentsListViewModel, StubDocumentTemplatesService, StubDocumentsService) { + let documentsList = DocumentsListViewModel(documents: documentsStub) + let viewModel = ServerTemplatesViewModel(service: templatesStub, documentsList: documentsList) + return (viewModel, documentsList, templatesStub, documentsStub) + } + + // MARK: - loadTemplates + + func test_givenSavedTemplates_whenLoading_thenMapsAndSurfacesThem() async { + // Happy path. + let (sut, _, templates, _) = makeSUT() + await templates.enqueueTemplates(success: [ + DocumentTemplateRef(id: "T1", title: "Weekly Review", relativePath: "_templates/weekly.md"), + DocumentTemplateRef(id: "T2", title: "Bug Report", relativePath: "_templates/bug.md") + ]) + + await sut.loadTemplates() + + XCTAssertEqual(sut.templates.map(\.id), ["T1", "T2"]) + XCTAssertEqual(sut.templates.map(\.title), ["Weekly Review", "Bug Report"]) + XCTAssertNil(sut.loadError) + XCTAssertTrue(sut.hasLoaded) + XCTAssertFalse(sut.isEmpty) + } + + func test_givenEmptyResponse_whenLoading_thenReportsEmptyStateWithoutError() async { + // Empty boundary. + let (sut, _, templates, _) = makeSUT() + await templates.enqueueTemplates(success: []) + + await sut.loadTemplates() + + XCTAssertTrue(sut.templates.isEmpty) + XCTAssertTrue(sut.isEmpty) + XCTAssertTrue(sut.hasLoaded) + XCTAssertNil(sut.loadError) + } + + func test_givenAPIFailure_whenLoading_thenSurfacesLoadErrorNonFatally() async { + // Upstream failure — non-fatal: templates stays empty, error is set, + // and the picker keeps rendering the built-in section. + let (sut, _, templates, _) = makeSUT() + let failure = TestError.upstream("templates denied") + await templates.enqueueTemplates(failure: failure) + + await sut.loadTemplates() + + XCTAssertEqual(sut.loadError as? TestError, failure) + XCTAssertTrue(sut.templates.isEmpty) + XCTAssertTrue(sut.hasLoaded) + // Empty-state row is suppressed while a load error is showing (the view + // prefers the error row), but the model still reports empty. + XCTAssertTrue(sut.isEmpty) + } + + func test_givenTemplateWithWhitespacePath_whenLoading_thenPreservesTitleAndPath() async { + // Boundary: a template whose relativePath is nil / whitespace still maps + // cleanly (the view guards the path display, the model does not mangle it). + let (sut, _, templates, _) = makeSUT() + await templates.enqueueTemplates(success: [ + DocumentTemplateRef(id: "T3", title: "Untitled Template", relativePath: nil) + ]) + + await sut.loadTemplates() + + XCTAssertEqual(sut.templates.first?.id, "T3") + XCTAssertEqual(sut.templates.first?.title, "Untitled Template") + XCTAssertNil(sut.templates.first?.relativePath) + } + + // MARK: - createFromTemplate + + func test_givenServerTemplate_whenCreating_thenCallsServiceReloadsListAndReturnsNewDoc() async { + // Happy path — create calls the service with the template id, reloads + // the documents list, and returns + selects the newly-appeared doc. + let documentsStub = StubDocumentsService() + // The documents-list reload after create returns a page containing the + // brand-new document (id "NEW") that was not present before. + await documentsStub.enqueueDocuments(success: [ + DocumentsFixtures.document(id: "NEW", title: "From Template") + ]) + let (sut, documentsList, templates, _) = makeSUT(documentsStub: documentsStub) + await templates.enqueueCreateSuccess() + let ref = DocumentTemplateRef(id: "T1", title: "Weekly Review") + + let created = await sut.createFromTemplate(ref) + + XCTAssertEqual(created?.id, "NEW") + XCTAssertEqual(documentsList.selectedDocumentID, "NEW") + XCTAssertNil(sut.createError) + let recorded = await templates.recorded + XCTAssertEqual(recorded, [.init(kind: .createFromTemplate(templateDocumentId: "T1"))]) + } + + func test_givenCreateAlreadyPending_whenCreatingSameTemplate_thenSecondCallIsNoOp() async { + // Invalid / debounce — a second create for the same id while one is in + // flight is rejected before touching the service (no second call). + let documentsStub = StubDocumentsService() + let templates = StubDocumentTemplatesService() + let (sut, _, _, _) = makeSUT(documentsStub: documentsStub, templatesStub: templates) + let ref = DocumentTemplateRef(id: "T1", title: "Weekly Review") + + // Stage exactly one create + one reload page; if the debounce leaks a + // second call the stub's create queue drains and the second throws. + await templates.enqueueCreateSuccess() + await documentsStub.enqueueDocuments(success: [DocumentsFixtures.document(id: "NEW")]) + + // Fire two creates for the same id concurrently. The debounce set makes + // one of them a no-op. + async let first = sut.createFromTemplate(ref) + async let second = sut.createFromTemplate(ref) + let results = await [first, second] + + let successes = results.compactMap { $0 } + XCTAssertEqual(successes.count, 1, "exactly one create should reach the service") + let createCalls = await templates.recorded.filter { + $0 == .init(kind: .createFromTemplate(templateDocumentId: "T1")) + } + XCTAssertEqual(createCalls.count, 1, "the debounce set must suppress the duplicate call") + } + + func test_givenAPIFailure_whenCreating_thenSurfacesCreateErrorAndDoesNotReload() async { + // Upstream failure — the create throws, so `createError` is set, the + // method returns nil, and the documents list is never reloaded. + let documentsStub = StubDocumentsService() + let (sut, documentsList, templates, _) = makeSUT(documentsStub: documentsStub) + let failure = TestError.upstream("create denied") + await templates.enqueueCreate(failure: failure) + let ref = DocumentTemplateRef(id: "T1", title: "Weekly Review") + + let created = await sut.createFromTemplate(ref) + + XCTAssertNil(created) + XCTAssertEqual(sut.createError as? TestError, failure) + // No documents-reload happened (the list never fetched). + let documentsCalls = await documentsStub.recorded.filter { + if case .documents = $0.kind { return true } + return false + } + XCTAssertTrue(documentsCalls.isEmpty) + XCTAssertNil(documentsList.selectedDocumentID) + // The pending debounce entry is released so a retry is possible. + XCTAssertFalse(sut.pendingTemplateIDs.contains("T1")) + } + + func test_givenReloadReturnsNoNewDocuments_whenCreating_thenFallsBackToFirstDoc() async { + // Boundary — the reload page contains only pre-existing ids, so the + // "first new id" lookup finds nothing and falls back to the first doc. + let documentsStub = StubDocumentsService() + // Seed the list so an id is already present before the create. + await documentsStub.enqueueDocuments(success: [DocumentsFixtures.document(id: "OLD")]) + let (sut, documentsList, templates, _) = makeSUT(documentsStub: documentsStub) + await documentsList.reload(in: nil) + + await templates.enqueueCreateSuccess() + // The post-create reload returns the same single (pre-existing) doc. + await documentsStub.enqueueDocuments(success: [DocumentsFixtures.document(id: "OLD")]) + let ref = DocumentTemplateRef(id: "T1", title: "Weekly Review") + + let created = await sut.createFromTemplate(ref) + + XCTAssertEqual(created?.id, "OLD", "falls back to the first loaded doc when no new id appears") + XCTAssertNil(sut.createError) + } + + // MARK: - seedDefaults + + func test_givenNoTemplates_whenSeedingDefaults_thenSeedsThenReloadsTemplates() async { + // Happy path — seed calls the service then re-fetches so the seeded + // templates appear. + let (sut, _, templates, _) = makeSUT() + await templates.enqueueSeedSuccess() + await templates.enqueueTemplates(success: [ + DocumentTemplateRef(id: "S1", title: "Seeded Meeting Notes") + ]) + + await sut.seedDefaults() + + XCTAssertEqual(sut.templates.map(\.id), ["S1"]) + XCTAssertFalse(sut.isEmpty) + XCTAssertNil(sut.loadError) + let recorded = await templates.recorded + XCTAssertEqual(recorded, [.init(kind: .seedDefaultTemplates), .init(kind: .templates)]) + } + + func test_givenAPIFailure_whenSeedingDefaults_thenSurfacesLoadErrorAndDoesNotReload() async { + // Upstream failure — seed throws, error is surfaced non-fatally, and the + // follow-up templates re-fetch never fires. + let (sut, _, templates, _) = makeSUT() + let failure = TestError.upstream("seed denied") + await templates.enqueueSeed(failure: failure) + + await sut.seedDefaults() + + XCTAssertEqual(sut.loadError as? TestError, failure) + XCTAssertFalse(sut.isLoading) + let recorded = await templates.recorded + XCTAssertEqual(recorded, [.init(kind: .seedDefaultTemplates)]) + } +} diff --git a/AppTests/ShareLinksViewModelTests.swift b/AppTests/ShareLinksViewModelTests.swift new file mode 100644 index 0000000..a58d3f8 --- /dev/null +++ b/AppTests/ShareLinksViewModelTests.swift @@ -0,0 +1,206 @@ +// ShareLinksViewModelTests +// +// BDD-named tests for the Share Links view model (the-gaps.md G3). Covers +// the required quartet plus the pattern-specific additions: +// - happy: load populates active links; create prepends the server link. +// - invalid input: the subscriber-gate rejects create *before* the HTTP +// round-trip — asserted via the upsell flag and (per the gate design) +// the recorded-call log; a whitespace-only nothing case is covered by +// the empty/boundary member. +// - upstream failure: a failing load surfaces the error; a failing revoke +// rolls the optimistic prune back (required optimistic-UI rollback). +// - empty / boundary: an empty links response reports "loaded, none". +// - subscriber gate: `subscriberRequired` raises the upsell, not `error`, +// and does not add a link. +// - both targets: the list and document halves dispatch to the matching +// service methods (asserted on the recorded-call log). +// +// The view model is `@MainActor`; the stub is an `actor`, so recorded-call +// assertions `await` the stub. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ShareLinksViewModelTests: XCTestCase { + + // MARK: - Helpers + + private func makeViewModel(target: ShareTarget = .list(id: "L1")) -> (ShareLinksViewModel, StubSharingService) { + let service = StubSharingService() + let vm = ShareLinksViewModel(service: service, target: target) + return (vm, service) + } + + private func link(_ token: String, role: ShareRole = .watcher, revokedAt: Date? = nil) -> ShareLink { + ShareLink( + token: token, + url: URL(string: "https://interlinedlist.com/lists/shared/\(token)"), + role: role, + expiresAt: nil, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + revokedAt: revokedAt + ) + } + + // MARK: - Happy path + + func test_givenActiveLinks_whenLoading_thenPublishesOnlyNonRevoked() async { + let (vm, service) = makeViewModel() + await service.enqueueLinks(success: [link("t1"), link("t2", revokedAt: Date()), link("t3")]) + + await vm.load() + + XCTAssertEqual(vm.links.map(\.token), ["t1", "t3"], "Revoked links must be filtered out") + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .listLinks(listId: "L1"))]) + } + + func test_givenSubscriber_whenCreating_thenPrependsServerReturnedLink() async { + let (vm, service) = makeViewModel() + vm.seedForTest(links: [link("old")]) + await service.enqueueCreate(success: link("new", role: .collaborator)) + vm.newRole = .collaborator + + let created = await vm.create() + + XCTAssertEqual(created?.token, "new") + XCTAssertEqual(vm.links.map(\.token), ["new", "old"], "New link is prepended") + XCTAssertFalse(vm.showSubscriberUpsell) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .createListLink(listId: "L1", role: .collaborator, expiresAt: nil))]) + } + + // MARK: - Invalid input (subscriber gate rejects before surfacing as error) + + func test_givenFreeAccount_whenCreating_thenRaisesUpsellAndDoesNotAddLink() async { + let (vm, service) = makeViewModel() + vm.seedForTest(links: [link("only")]) + // The domain service throws `subscriberRequired` before any HTTP. + await service.enqueueCreate(failure: SharingError.subscriberRequired) + + let created = await vm.create() + + XCTAssertNil(created) + XCTAssertTrue(vm.showSubscriberUpsell, "Free-tier create must raise the upsell, not an error") + XCTAssertNil(vm.error, "subscriberRequired must not surface as a generic error") + XCTAssertEqual(vm.links.map(\.token), ["only"], "No link is added on a gated create") + } + + func test_givenUpsellShown_whenDismissed_thenUpsellCleared() async { + let (vm, service) = makeViewModel() + await service.enqueueCreate(failure: SharingError.subscriberRequired) + _ = await vm.create() + XCTAssertTrue(vm.showSubscriberUpsell) + + vm.dismissUpsell() + + XCTAssertFalse(vm.showSubscriberUpsell) + } + + // MARK: - Upstream API failure + + func test_givenUpstreamFailure_whenLoading_thenSurfacesErrorAndMarksLoaded() async { + let (vm, service) = makeViewModel() + await service.enqueueLinks(failure: TestError.upstream("net")) + + await vm.load() + + XCTAssertEqual(vm.error as? TestError, .upstream("net")) + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertTrue(vm.links.isEmpty) + } + + // MARK: - Optimistic revoke rollback (required) + + func test_givenRevokeFails_whenRevoking_thenRestoresSnapshotAndSurfacesError() async { + let (vm, service) = makeViewModel() + vm.seedForTest(links: [link("keep"), link("drop")]) + await service.enqueueRevoke(failure: TestError.upstream("boom")) + + await vm.revoke(token: "drop") + + XCTAssertEqual(vm.links.map(\.token), ["keep", "drop"], "Optimistic prune must roll back on failure") + XCTAssertEqual(vm.error as? TestError, .upstream("boom")) + } + + func test_givenRevokeSucceeds_whenRevoking_thenLinkRemovedAndNoError() async { + let (vm, service) = makeViewModel() + vm.seedForTest(links: [link("keep"), link("drop")]) + await service.enqueueRevoke(success: true) + + await vm.revoke(token: "drop") + + XCTAssertEqual(vm.links.map(\.token), ["keep"]) + XCTAssertNil(vm.error) + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .revokeListLink(listId: "L1", token: "drop"))]) + } + + func test_givenServerReportsNotRevoked_whenRevoking_thenRestoresSnapshot() async { + let (vm, service) = makeViewModel() + vm.seedForTest(links: [link("keep"), link("drop")]) + // Server returns revoked == false → the link is still live. + await service.enqueueRevoke(success: false) + + await vm.revoke(token: "drop") + + XCTAssertEqual(vm.links.map(\.token), ["keep", "drop"], "A false 'revoked' flag must restore the link") + XCTAssertNil(vm.error) + } + + // MARK: - Empty / boundary + + func test_givenEmptyResponse_whenLoading_thenReportsLoadedWithNoLinks() async { + let (vm, service) = makeViewModel() + await service.enqueueLinks(success: []) + + await vm.load() + + XCTAssertTrue(vm.links.isEmpty) + XCTAssertTrue(vm.hasLoadedOnce) + XCTAssertNil(vm.error) + } + + // MARK: - Target dispatch (documents half) + + func test_givenDocumentTarget_whenCreating_thenCallsDocumentEndpoint() async { + let (vm, service) = makeViewModel(target: .document(id: "D9")) + await service.enqueueCreate(success: link("doc-link", role: .manager)) + vm.newRole = .manager + + _ = await vm.create() + + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .createDocumentLink(documentId: "D9", role: .manager, expiresAt: nil))]) + XCTAssertEqual(vm.links.map(\.token), ["doc-link"]) + } + + func test_givenDocumentTarget_whenLoading_thenCallsDocumentLinksEndpoint() async { + let (vm, service) = makeViewModel(target: .document(id: "D9")) + await service.enqueueLinks(success: [link("t1")]) + + await vm.load() + + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .documentLinks(documentId: "D9"))]) + } + + // MARK: - Expiry threading + + func test_givenExpirySet_whenCreating_thenExpiryForwardedToService() async { + let (vm, service) = makeViewModel() + let expiry = Date(timeIntervalSince1970: 1_800_000_000) + vm.newExpiresAt = expiry + await service.enqueueCreate(success: link("t1")) + + _ = await vm.create() + + let recorded = await service.recorded + XCTAssertEqual(recorded, [.init(kind: .createListLink(listId: "L1", role: .watcher, expiresAt: expiry))]) + } +} diff --git a/AppTests/ShareURLParserTests.swift b/AppTests/ShareURLParserTests.swift new file mode 100644 index 0000000..548003f --- /dev/null +++ b/AppTests/ShareURLParserTests.swift @@ -0,0 +1,129 @@ +// ShareURLParserTests +// +// BDD-named tests for the pure share-URL parser (the-gaps.md G3). The +// parser feeds both the `interlinedlist://` deep-link handler and the +// pasted-URL path, so its recognition surface is exhaustively covered +// here: +// - happy: https + custom-scheme list/document URLs parse to the right +// kind + token. +// - invalid input: a non-share URL (timeline, profile, garbage) returns +// nil; the OAuth-callback URL returns nil so the existing handler is +// untouched. +// - upstream failure: N/A (pure parser — no service). +// - empty / boundary: a trailing-slash / empty-token URL returns nil; +// whitespace around a pasted string is trimmed. +// - `handle(_:)` posts only for recognized URLs and returns false for +// the OAuth callback (routing test). + +import XCTest +@testable import InterlinedList + +@MainActor +final class ShareURLParserTests: XCTestCase { + + // MARK: - Happy path (https) + + func test_givenHttpsListShareURL_whenParsing_thenReturnsListKindAndToken() { + let url = URL(string: "https://interlinedlist.com/lists/shared/abc123")! + XCTAssertEqual(ShareURLParser.parse(url), ParsedShare(kind: .list, token: "abc123")) + } + + func test_givenHttpsDocumentShareURL_whenParsing_thenReturnsDocumentKindAndToken() { + let url = URL(string: "https://interlinedlist.com/documents/shared/DoC-9")! + XCTAssertEqual(ShareURLParser.parse(url), ParsedShare(kind: .document, token: "DoC-9")) + } + + func test_givenWwwHostShareURL_whenParsing_thenHostIgnoredAndParses() { + let url = URL(string: "https://www.interlinedlist.com/lists/shared/tok")! + XCTAssertEqual(ShareURLParser.parse(url), ParsedShare(kind: .list, token: "tok")) + } + + // MARK: - Happy path (custom scheme) + + func test_givenCustomSchemeListURL_whenParsing_thenParsesHostAsResource() { + // interlinedlist://lists/shared/tok → host == "lists". + let url = URL(string: "interlinedlist://lists/shared/tok")! + XCTAssertEqual(ShareURLParser.parse(url), ParsedShare(kind: .list, token: "tok")) + } + + func test_givenCustomSchemeShareRouterURL_whenParsing_thenSkipsShareSegment() { + // interlinedlist://share/documents/shared/tok → host == "share". + let url = URL(string: "interlinedlist://share/documents/shared/tok")! + XCTAssertEqual(ShareURLParser.parse(url), ParsedShare(kind: .document, token: "tok")) + } + + // MARK: - Invalid input + + func test_givenNonShareURL_whenParsing_thenReturnsNil() { + XCTAssertNil(ShareURLParser.parse(URL(string: "https://interlinedlist.com/timeline")!)) + XCTAssertNil(ShareURLParser.parse(URL(string: "https://interlinedlist.com/lists/L1")!)) + } + + func test_givenExtraTrailingSegments_whenParsing_thenReturnsNil() { + // The token must be the last segment; extra segments after it mean + // this is not the canonical share URL, so it must not resolve. + let url = URL(string: "https://interlinedlist.com/lists/shared/tok/extra/more")! + XCTAssertNil(ShareURLParser.parse(url)) + } + + func test_givenOAuthCallbackURL_whenParsing_thenReturnsNil() { + // The OAuth callback must not be mistaken for a share link. + let url = URL(string: "interlinedlist://oauth/callback?code=xyz&state=abc")! + XCTAssertNil(ShareURLParser.parse(url)) + } + + func test_givenWrongResourceWord_whenParsing_thenReturnsNil() { + let url = URL(string: "https://interlinedlist.com/folders/shared/tok")! + XCTAssertNil(ShareURLParser.parse(url)) + } + + // MARK: - Empty / boundary + + func test_givenEmptyToken_whenParsing_thenReturnsNil() { + // Trailing slash → the token segment is empty/absent. + let url = URL(string: "https://interlinedlist.com/lists/shared/")! + XCTAssertNil(ShareURLParser.parse(url)) + } + + func test_givenWhitespaceWrappedString_whenParsing_thenTrimmedAndParses() { + let parsed = ShareURLParser.parse(string: " https://interlinedlist.com/lists/shared/tok\n") + XCTAssertEqual(parsed, ParsedShare(kind: .list, token: "tok")) + } + + func test_givenGarbageString_whenParsing_thenReturnsNil() { + XCTAssertNil(ShareURLParser.parse(string: "not a url at all !!")) + XCTAssertNil(ShareURLParser.parse(string: " ")) + } + + // MARK: - webURL builder + + func test_givenTokenAndBase_whenBuildingWebURL_thenComposesCanonicalPath() { + let base = URL(string: "https://interlinedlist.com")! + let listURL = ShareURLParser.webURL(base: base, kind: .list, token: "tok") + XCTAssertEqual(listURL?.absoluteString, "https://interlinedlist.com/lists/shared/tok") + let docURL = ShareURLParser.webURL(base: base, kind: .document, token: "tok") + XCTAssertEqual(docURL?.absoluteString, "https://interlinedlist.com/documents/shared/tok") + } + + // MARK: - handle() routing (posts only for share links) + + func test_givenShareURL_whenHandling_thenPostsParsedAndReturnsTrue() { + var captured: ParsedShare? + let handled = ShareLinkDeepLink.handle( + URL(string: "https://interlinedlist.com/documents/shared/tok")!, + post: { captured = $0 } + ) + XCTAssertTrue(handled) + XCTAssertEqual(captured, ParsedShare(kind: .document, token: "tok")) + } + + func test_givenOAuthURL_whenHandling_thenDoesNotPostAndReturnsFalse() { + var captured: ParsedShare? + let handled = ShareLinkDeepLink.handle( + URL(string: "interlinedlist://oauth/callback?code=1")!, + post: { captured = $0 } + ) + XCTAssertFalse(handled, "The OAuth callback must fall through to its own handler") + XCTAssertNil(captured) + } +} diff --git a/AppTests/Support/MessageFixtures.swift b/AppTests/Support/MessageFixtures.swift index f41864d..fb2f058 100644 --- a/AppTests/Support/MessageFixtures.swift +++ b/AppTests/Support/MessageFixtures.swift @@ -30,7 +30,8 @@ enum MessageFixtures { repostCount: Int = 0, replyCount: Int? = nil, parentID: String? = nil, - repost: Repost? = nil + repost: Repost? = nil, + linkPreviews: [LinkPreview] = [] ) -> Message { Message( id: id, @@ -46,7 +47,8 @@ enum MessageFixtures { replyCount: replyCount, parentID: parentID, repost: repost, - scheduledAt: nil + scheduledAt: nil, + linkPreviews: linkPreviews ) } diff --git a/AppTests/Support/StubDirectMessagesService.swift b/AppTests/Support/StubDirectMessagesService.swift new file mode 100644 index 0000000..a048692 --- /dev/null +++ b/AppTests/Support/StubDirectMessagesService.swift @@ -0,0 +1,136 @@ +// StubDirectMessagesService +// +// Deterministic `DirectMessagesServicing` stub for App-layer view-model +// tests of the Direct Messages feature (the-gaps.md G1). Mirrors the +// project's other stubs (`StubSearchService`, `StubModerationService`): +// an actor with one FIFO outcome queue per call site + a recorded-call +// log so tests can assert both the returned value and that the right +// call was (or was not) made. +// +// The `send` guard (`DirectMessagesError.emptyMessage` on a blank body +// with no images) lives in the concrete `DirectMessagesService`, not in +// the protocol, so this stub does NOT re-implement it — a view model +// under test is expected to guard blank input itself before the service +// is reached, and the "invalid input" quartet case asserts on `recorded` +// staying empty. + +import Foundation +import InterlinedDomain + +struct RecordedDMCall: Sendable, Equatable { + enum Kind: Sendable, Equatable { + case folder(folder: DMFolder, cursor: String?) + case thread(username: String, cursor: String?) + case threadUpdates(username: String, since: String?) + case send(recipientId: String, body: String, imageURLs: [String]) + case recipients + case unreadCount + case markRead(id: String) + case trash(id: String) + case restore(id: String) + } + let kind: Kind +} + +actor StubDirectMessagesService: DirectMessagesServicing { + + private var folderOutcomes: [Result] = [] + private var threadOutcomes: [Result] = [] + private var threadUpdatesOutcomes: [Result] = [] + private var sendOutcomes: [Result] = [] + private var recipientsOutcomes: [Result<[UserSummary], Error>] = [] + private var unreadCountOutcomes: [Result] = [] + private var markReadOutcomes: [Result] = [] + private var trashOutcomes: [Result] = [] + private var restoreOutcomes: [Result] = [] + + private(set) var recorded: [RecordedDMCall] = [] + + // MARK: Programmable enqueue helpers + + func enqueueFolder(success value: DMPage) { folderOutcomes.append(.success(value)) } + func enqueueFolder(failure error: Error) { folderOutcomes.append(.failure(error)) } + + func enqueueThread(success value: DMThread) { threadOutcomes.append(.success(value)) } + func enqueueThread(failure error: Error) { threadOutcomes.append(.failure(error)) } + + func enqueueThreadUpdates(success value: DMThread) { threadUpdatesOutcomes.append(.success(value)) } + func enqueueThreadUpdates(failure error: Error) { threadUpdatesOutcomes.append(.failure(error)) } + + func enqueueSend(success value: DirectMessage) { sendOutcomes.append(.success(value)) } + func enqueueSend(failure error: Error) { sendOutcomes.append(.failure(error)) } + + func enqueueRecipients(success value: [UserSummary]) { recipientsOutcomes.append(.success(value)) } + func enqueueRecipients(failure error: Error) { recipientsOutcomes.append(.failure(error)) } + + func enqueueUnreadCount(success value: Int) { unreadCountOutcomes.append(.success(value)) } + func enqueueUnreadCount(failure error: Error) { unreadCountOutcomes.append(.failure(error)) } + + func enqueueMarkReadSuccess() { markReadOutcomes.append(.success(())) } + func enqueueMarkRead(failure error: Error) { markReadOutcomes.append(.failure(error)) } + + func enqueueTrashSuccess() { trashOutcomes.append(.success(())) } + func enqueueTrash(failure error: Error) { trashOutcomes.append(.failure(error)) } + + func enqueueRestoreSuccess() { restoreOutcomes.append(.success(())) } + func enqueueRestore(failure error: Error) { restoreOutcomes.append(.failure(error)) } + + // MARK: DirectMessagesServicing + + func folder(_ folder: DMFolder, cursor: String?) async throws -> DMPage { + recorded.append(.init(kind: .folder(folder: folder, cursor: cursor))) + return try take(&folderOutcomes, label: "folder") + } + + func thread(username: String, cursor: String?) async throws -> DMThread { + recorded.append(.init(kind: .thread(username: username, cursor: cursor))) + return try take(&threadOutcomes, label: "thread") + } + + func threadUpdates(username: String, since: String?) async throws -> DMThread { + recorded.append(.init(kind: .threadUpdates(username: username, since: since))) + return try take(&threadUpdatesOutcomes, label: "threadUpdates") + } + + func send(recipientId: String, body: String, imageURLs: [String]) async throws -> DirectMessage { + recorded.append(.init(kind: .send(recipientId: recipientId, body: body, imageURLs: imageURLs))) + return try take(&sendOutcomes, label: "send") + } + + func recipients() async throws -> [UserSummary] { + recorded.append(.init(kind: .recipients)) + return try take(&recipientsOutcomes, label: "recipients") + } + + func unreadCount() async throws -> Int { + recorded.append(.init(kind: .unreadCount)) + return try take(&unreadCountOutcomes, label: "unreadCount") + } + + func markRead(id: String) async throws { + recorded.append(.init(kind: .markRead(id: id))) + let _: Void = try take(&markReadOutcomes, label: "markRead") + } + + func trash(id: String) async throws { + recorded.append(.init(kind: .trash(id: id))) + let _: Void = try take(&trashOutcomes, label: "trash") + } + + func restore(id: String) async throws { + recorded.append(.init(kind: .restore(id: id))) + let _: Void = try take(&restoreOutcomes, label: "restore") + } + + private func take(_ queue: inout [Result], label: String) throws -> T { + guard !queue.isEmpty else { throw StubError.noOutcome(label: label) } + switch queue.removeFirst() { + case .success(let value): return value + case .failure(let error): throw error + } + } + + enum StubError: Error, Equatable { + case noOutcome(label: String) + } +} diff --git a/AppTests/Support/StubDocumentTemplatesService.swift b/AppTests/Support/StubDocumentTemplatesService.swift new file mode 100644 index 0000000..6b27c7b --- /dev/null +++ b/AppTests/Support/StubDocumentTemplatesService.swift @@ -0,0 +1,68 @@ +// StubDocumentTemplatesService +// +// Deterministic `DocumentTemplatesServicing` stub for App-layer view-model +// tests of the server document-templates feature (the-gaps.md G12). Mirrors the +// project's other stubs: an actor with one FIFO outcome queue per call site plus +// a recorded-call log so tests can assert both the surfaced result and that the +// right calls were (or were not) made. + +import Foundation +import InterlinedDomain + +struct RecordedDocumentTemplatesCall: Sendable, Equatable { + enum Kind: Sendable, Equatable { + case templates + case createFromTemplate(templateDocumentId: String) + case seedDefaultTemplates + } + let kind: Kind +} + +actor StubDocumentTemplatesService: DocumentTemplatesServicing { + + private var templatesOutcomes: [Result<[DocumentTemplateRef], Error>] = [] + private var createOutcomes: [Result] = [] + private var seedOutcomes: [Result] = [] + + private(set) var recorded: [RecordedDocumentTemplatesCall] = [] + + // MARK: Programmable enqueue helpers + + func enqueueTemplates(success value: [DocumentTemplateRef]) { templatesOutcomes.append(.success(value)) } + func enqueueTemplates(failure error: Error) { templatesOutcomes.append(.failure(error)) } + + func enqueueCreateSuccess() { createOutcomes.append(.success(())) } + func enqueueCreate(failure error: Error) { createOutcomes.append(.failure(error)) } + + func enqueueSeedSuccess() { seedOutcomes.append(.success(())) } + func enqueueSeed(failure error: Error) { seedOutcomes.append(.failure(error)) } + + // MARK: DocumentTemplatesServicing + + func templates() async throws -> [DocumentTemplateRef] { + recorded.append(.init(kind: .templates)) + return try take(&templatesOutcomes, label: "templates") + } + + func createFromTemplate(templateDocumentId: String) async throws { + recorded.append(.init(kind: .createFromTemplate(templateDocumentId: templateDocumentId))) + let _: Void = try take(&createOutcomes, label: "createFromTemplate") + } + + func seedDefaultTemplates() async throws { + recorded.append(.init(kind: .seedDefaultTemplates)) + let _: Void = try take(&seedOutcomes, label: "seedDefaultTemplates") + } + + private func take(_ queue: inout [Result], label: String) throws -> T { + guard !queue.isEmpty else { throw StubError.noOutcome(label: label) } + switch queue.removeFirst() { + case .success(let value): return value + case .failure(let error): throw error + } + } + + enum StubError: Error, Equatable { + case noOutcome(label: String) + } +} diff --git a/AppTests/Support/StubListFoldersService.swift b/AppTests/Support/StubListFoldersService.swift new file mode 100644 index 0000000..7bb7559 --- /dev/null +++ b/AppTests/Support/StubListFoldersService.swift @@ -0,0 +1,102 @@ +// StubListFoldersService +// +// Deterministic `ListFoldersServicing` stub for App-layer view-model +// tests of the list-folders feature (the-gaps.md G6). Mirrors the +// project's other stubs: an actor with one FIFO outcome queue per call +// site + a recorded-call log. +// +// `tree()` is not stubbed independently — the real `ListFoldersService` +// derives it from `folders()`, and the view model calls `tree()`. The +// stub therefore backs `tree()` with its own queue so tests can stage the +// assembled tree directly without reconstructing `folders()` mapping. + +import Foundation +import InterlinedDomain + +struct RecordedListFoldersCall: Sendable, Equatable { + enum Kind: Sendable, Equatable { + case folders + case tree + case create(name: String, parentId: String?) + case rename(id: String, name: String) + case move(id: String, parentId: String?) + case delete(id: String) + } + let kind: Kind +} + +actor StubListFoldersService: ListFoldersServicing { + + private var foldersOutcomes: [Result<[ListFolder], Error>] = [] + private var treeOutcomes: [Result<[ListFolderNode], Error>] = [] + private var createOutcomes: [Result] = [] + private var renameOutcomes: [Result] = [] + private var moveOutcomes: [Result] = [] + private var deleteOutcomes: [Result] = [] + + private(set) var recorded: [RecordedListFoldersCall] = [] + + // MARK: Programmable enqueue helpers + + func enqueueFolders(success value: [ListFolder]) { foldersOutcomes.append(.success(value)) } + func enqueueFolders(failure error: Error) { foldersOutcomes.append(.failure(error)) } + + func enqueueTree(success value: [ListFolderNode]) { treeOutcomes.append(.success(value)) } + func enqueueTree(failure error: Error) { treeOutcomes.append(.failure(error)) } + + func enqueueCreate(success value: ListFolder) { createOutcomes.append(.success(value)) } + func enqueueCreate(failure error: Error) { createOutcomes.append(.failure(error)) } + + func enqueueRename(success value: ListFolder) { renameOutcomes.append(.success(value)) } + func enqueueRename(failure error: Error) { renameOutcomes.append(.failure(error)) } + + func enqueueMove(success value: ListFolder) { moveOutcomes.append(.success(value)) } + func enqueueMove(failure error: Error) { moveOutcomes.append(.failure(error)) } + + func enqueueDeleteSuccess() { deleteOutcomes.append(.success(())) } + func enqueueDelete(failure error: Error) { deleteOutcomes.append(.failure(error)) } + + // MARK: ListFoldersServicing + + func folders() async throws -> [ListFolder] { + recorded.append(.init(kind: .folders)) + return try take(&foldersOutcomes, label: "folders") + } + + func tree() async throws -> [ListFolderNode] { + recorded.append(.init(kind: .tree)) + return try take(&treeOutcomes, label: "tree") + } + + func create(name: String, parentId: String?) async throws -> ListFolder { + recorded.append(.init(kind: .create(name: name, parentId: parentId))) + return try take(&createOutcomes, label: "create") + } + + func rename(id: String, name: String) async throws -> ListFolder { + recorded.append(.init(kind: .rename(id: id, name: name))) + return try take(&renameOutcomes, label: "rename") + } + + func move(id: String, toParent parentId: String?) async throws -> ListFolder { + recorded.append(.init(kind: .move(id: id, parentId: parentId))) + return try take(&moveOutcomes, label: "move") + } + + func delete(id: String) async throws { + recorded.append(.init(kind: .delete(id: id))) + let _: Void = try take(&deleteOutcomes, label: "delete") + } + + private func take(_ queue: inout [Result], label: String) throws -> T { + guard !queue.isEmpty else { throw StubError.noOutcome(label: label) } + switch queue.removeFirst() { + case .success(let value): return value + case .failure(let error): throw error + } + } + + enum StubError: Error, Equatable { + case noOutcome(label: String) + } +} diff --git a/AppTests/Support/StubListsService.swift b/AppTests/Support/StubListsService.swift index 68ebe45..ba931e9 100644 --- a/AppTests/Support/StubListsService.swift +++ b/AppTests/Support/StubListsService.swift @@ -68,6 +68,12 @@ actor StubListsService: ListsServicing { private(set) var recorded: [RecordedListsCall] = [] + /// The full `ListSchema` passed to the most recent `updateSchema` call. + /// The recorded-call log only captures `fieldsCount`; tests that need to + /// assert per-field detail (e.g. a `select` column's `enumValues` round- + /// tripping through save) read this instead. + private(set) var lastUpdatedSchema: ListSchema? + // MARK: Programmable enqueue helpers func enqueueMyLists(success page: OwnedListsPage) { myListsOutcomes.append(.success(page)) } func enqueueMyLists(failure error: Error) { myListsOutcomes.append(.failure(error)) } @@ -176,6 +182,7 @@ actor StubListsService: ListsServicing { func updateSchema(of listId: String, schema: ListSchema) async throws -> ListSchema { recorded.append(.init(kind: .updateSchema(listId: listId, fieldsCount: schema.fields.count))) + lastUpdatedSchema = schema return try take(&updateSchemaOutcomes, label: "updateSchema") } diff --git a/AppTests/Support/StubMessagesService.swift b/AppTests/Support/StubMessagesService.swift index d61768a..b8f36d2 100644 --- a/AppTests/Support/StubMessagesService.swift +++ b/AppTests/Support/StubMessagesService.swift @@ -30,7 +30,7 @@ struct RecordedMessagesCall: Sendable, Equatable { case dig(messageId: String) case undig(messageId: String) // M6 write surface (additive — see the M6 conformance block below). - case createPost(body: String, tags: [String], visibility: Visibility, imageURLs: [String], videoURLs: [String], scheduledAt: Date?, mastodonProviderIds: [String], crossPostToBluesky: Bool, crossPostToLinkedIn: Bool) + case createPost(body: String, tags: [String], visibility: Visibility, imageURLs: [String], videoURLs: [String], scheduledAt: Date?, mastodonProviderIds: [String], crossPostToBluesky: Bool, crossPostToLinkedIn: Bool, crossPostToTwitter: Bool) case scheduledPosts case uploadImage(byteCount: Int) case uploadVideo(byteCount: Int, contentType: String) @@ -219,7 +219,8 @@ actor StubMessagesService: MessagesServicing { scheduledAt: Date?, mastodonProviderIds: [String], crossPostToBluesky: Bool, - crossPostToLinkedIn: Bool + crossPostToLinkedIn: Bool, + crossPostToTwitter: Bool ) async throws -> Message { recorded.append(.init(kind: .createPost( body: body, @@ -230,7 +231,8 @@ actor StubMessagesService: MessagesServicing { scheduledAt: scheduledAt, mastodonProviderIds: mastodonProviderIds, crossPostToBluesky: crossPostToBluesky, - crossPostToLinkedIn: crossPostToLinkedIn + crossPostToLinkedIn: crossPostToLinkedIn, + crossPostToTwitter: crossPostToTwitter ))) return try take(&createPostOutcomes, label: "createPost") } diff --git a/AppTests/Support/StubModerationService.swift b/AppTests/Support/StubModerationService.swift new file mode 100644 index 0000000..22e550e --- /dev/null +++ b/AppTests/Support/StubModerationService.swift @@ -0,0 +1,127 @@ +// StubModerationService +// +// Deterministic `ModerationServicing` stub for App-layer view-model tests +// of the moderation feature (the-gaps.md G2). Mirrors the project's other +// stubs: an actor with one FIFO outcome queue per call site + a recorded- +// call log. + +import Foundation +import InterlinedDomain + +struct RecordedModerationCall: Sendable, Equatable { + enum Kind: Sendable, Equatable { + case blockedUsers(limit: Int, offset: Int) + case mutedUsers(limit: Int, offset: Int) + case block(username: String) + case unblock(username: String) + case mute(username: String) + case unmute(username: String) + case reportUser(username: String, reason: ReportReason, detail: String?) + case reportMessage(id: String, reason: ReportReason, detail: String?) + case isBlocking(username: String) + } + let kind: Kind +} + +actor StubModerationService: ModerationServicing { + + private var blockedOutcomes: [Result<[ModeratedUser], Error>] = [] + private var mutedOutcomes: [Result<[ModeratedUser], Error>] = [] + private var blockOutcomes: [Result] = [] + private var unblockOutcomes: [Result] = [] + private var muteOutcomes: [Result] = [] + private var unmuteOutcomes: [Result] = [] + private var reportUserOutcomes: [Result] = [] + private var reportMessageOutcomes: [Result] = [] + private var isBlockingOutcomes: [Result] = [] + + private(set) var recorded: [RecordedModerationCall] = [] + + // MARK: Programmable enqueue helpers + + func enqueueBlocked(success value: [ModeratedUser]) { blockedOutcomes.append(.success(value)) } + func enqueueBlocked(failure error: Error) { blockedOutcomes.append(.failure(error)) } + + func enqueueMuted(success value: [ModeratedUser]) { mutedOutcomes.append(.success(value)) } + func enqueueMuted(failure error: Error) { mutedOutcomes.append(.failure(error)) } + + func enqueueBlockSuccess() { blockOutcomes.append(.success(())) } + func enqueueBlock(failure error: Error) { blockOutcomes.append(.failure(error)) } + + func enqueueUnblockSuccess() { unblockOutcomes.append(.success(())) } + func enqueueUnblock(failure error: Error) { unblockOutcomes.append(.failure(error)) } + + func enqueueMuteSuccess() { muteOutcomes.append(.success(())) } + func enqueueMute(failure error: Error) { muteOutcomes.append(.failure(error)) } + + func enqueueUnmuteSuccess() { unmuteOutcomes.append(.success(())) } + func enqueueUnmute(failure error: Error) { unmuteOutcomes.append(.failure(error)) } + + func enqueueReportUserSuccess() { reportUserOutcomes.append(.success(())) } + func enqueueReportUser(failure error: Error) { reportUserOutcomes.append(.failure(error)) } + + func enqueueReportMessageSuccess() { reportMessageOutcomes.append(.success(())) } + func enqueueReportMessage(failure error: Error) { reportMessageOutcomes.append(.failure(error)) } + + func enqueueIsBlocking(success value: Bool) { isBlockingOutcomes.append(.success(value)) } + func enqueueIsBlocking(failure error: Error) { isBlockingOutcomes.append(.failure(error)) } + + // MARK: ModerationServicing + + func blockedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] { + recorded.append(.init(kind: .blockedUsers(limit: limit, offset: offset))) + return try take(&blockedOutcomes, label: "blockedUsers") + } + + func mutedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] { + recorded.append(.init(kind: .mutedUsers(limit: limit, offset: offset))) + return try take(&mutedOutcomes, label: "mutedUsers") + } + + func block(username: String) async throws { + recorded.append(.init(kind: .block(username: username))) + let _: Void = try take(&blockOutcomes, label: "block") + } + + func unblock(username: String) async throws { + recorded.append(.init(kind: .unblock(username: username))) + let _: Void = try take(&unblockOutcomes, label: "unblock") + } + + func mute(username: String) async throws { + recorded.append(.init(kind: .mute(username: username))) + let _: Void = try take(&muteOutcomes, label: "mute") + } + + func unmute(username: String) async throws { + recorded.append(.init(kind: .unmute(username: username))) + let _: Void = try take(&unmuteOutcomes, label: "unmute") + } + + func reportUser(username: String, reason: ReportReason, detail: String?) async throws { + recorded.append(.init(kind: .reportUser(username: username, reason: reason, detail: detail))) + let _: Void = try take(&reportUserOutcomes, label: "reportUser") + } + + func reportMessage(id: String, reason: ReportReason, detail: String?) async throws { + recorded.append(.init(kind: .reportMessage(id: id, reason: reason, detail: detail))) + let _: Void = try take(&reportMessageOutcomes, label: "reportMessage") + } + + func isBlocking(username: String) async throws -> Bool { + recorded.append(.init(kind: .isBlocking(username: username))) + return try take(&isBlockingOutcomes, label: "isBlocking") + } + + private func take(_ queue: inout [Result], label: String) throws -> T { + guard !queue.isEmpty else { throw StubError.noOutcome(label: label) } + switch queue.removeFirst() { + case .success(let value): return value + case .failure(let error): throw error + } + } + + enum StubError: Error, Equatable { + case noOutcome(label: String) + } +} diff --git a/AppTests/Support/StubSearchService.swift b/AppTests/Support/StubSearchService.swift new file mode 100644 index 0000000..ccbe8d8 --- /dev/null +++ b/AppTests/Support/StubSearchService.swift @@ -0,0 +1,80 @@ +// StubSearchService +// +// Deterministic `SearchServicing` stub for App-layer view-model tests of +// the global search feature (the-gaps.md G5). Mirrors the project's other +// stubs: an actor with one FIFO outcome queue per resource + a recorded- +// call log. +// +// The three per-resource entry points (`messages` / `lists` / `documents`) +// are what the default `all(query:)` protocol extension fans out to, so +// staging the three queues drives `all(query:)` end-to-end. Blank queries +// are short-circuited by the extension before reaching the stub, so a +// blank-query test asserts on `recorded` being empty. + +import Foundation +import InterlinedDomain + +struct RecordedSearchCall: Sendable, Equatable { + enum Kind: Sendable, Equatable { + case messages(query: String, limit: Int, offset: Int) + case lists(query: String, limit: Int, offset: Int) + case documents(query: String, limit: Int, offset: Int) + } + let kind: Kind +} + +actor StubSearchService: SearchServicing { + + private var messagesOutcomes: [Result<[Message], Error>] = [] + private var listsOutcomes: [Result<[ListSummary], Error>] = [] + private var documentsOutcomes: [Result<[Document], Error>] = [] + + private(set) var recorded: [RecordedSearchCall] = [] + + // MARK: Programmable enqueue helpers + + func enqueueMessages(success value: [Message]) { messagesOutcomes.append(.success(value)) } + func enqueueMessages(failure error: Error) { messagesOutcomes.append(.failure(error)) } + + func enqueueLists(success value: [ListSummary]) { listsOutcomes.append(.success(value)) } + func enqueueLists(failure error: Error) { listsOutcomes.append(.failure(error)) } + + func enqueueDocuments(success value: [Document]) { documentsOutcomes.append(.success(value)) } + func enqueueDocuments(failure error: Error) { documentsOutcomes.append(.failure(error)) } + + /// Stages a full happy-path `all(query:)` result in one call. + func enqueueAll(messages: [Message], lists: [ListSummary], documents: [Document]) { + messagesOutcomes.append(.success(messages)) + listsOutcomes.append(.success(lists)) + documentsOutcomes.append(.success(documents)) + } + + // MARK: SearchServicing + + func messages(query: String, limit: Int, offset: Int) async throws -> [Message] { + recorded.append(.init(kind: .messages(query: query, limit: limit, offset: offset))) + return try take(&messagesOutcomes, label: "messages") + } + + func lists(query: String, limit: Int, offset: Int) async throws -> [ListSummary] { + recorded.append(.init(kind: .lists(query: query, limit: limit, offset: offset))) + return try take(&listsOutcomes, label: "lists") + } + + func documents(query: String, limit: Int, offset: Int) async throws -> [Document] { + recorded.append(.init(kind: .documents(query: query, limit: limit, offset: offset))) + return try take(&documentsOutcomes, label: "documents") + } + + private func take(_ queue: inout [Result], label: String) throws -> T { + guard !queue.isEmpty else { throw StubError.noOutcome(label: label) } + switch queue.removeFirst() { + case .success(let value): return value + case .failure(let error): throw error + } + } + + enum StubError: Error, Equatable { + case noOutcome(label: String) + } +} diff --git a/AppTests/Support/StubSharingService.swift b/AppTests/Support/StubSharingService.swift new file mode 100644 index 0000000..d93860b --- /dev/null +++ b/AppTests/Support/StubSharingService.swift @@ -0,0 +1,130 @@ +// StubSharingService +// +// Deterministic `SharingServicing` stub for App-layer view-model tests of +// the Share Links feature (the-gaps.md G3). Mirrors the project's other +// stubs: an actor with one FIFO outcome queue per call site + a recorded- +// call log so tests can assert *what* was called (and, for the invalid- +// input quartet member, that nothing was called). +// +// The list and document halves of the protocol share queues keyed by the +// logical operation (`listLinks` / `create` / `revoke` / `resolve` / +// `claim`) — a given view model only ever drives one half, so a single +// queue per operation keeps staging terse. `recorded` disambiguates by +// carrying the resource kind + id in each entry. + +import Foundation +import InterlinedDomain + +struct RecordedSharingCall: Sendable, Equatable { + enum Kind: Sendable, Equatable { + case listLinks(listId: String) + case createListLink(listId: String, role: ShareRole, expiresAt: Date?) + case revokeListLink(listId: String, token: String) + case resolveList(token: String) + case claimList(token: String) + + case documentLinks(documentId: String) + case createDocumentLink(documentId: String, role: ShareRole, expiresAt: Date?) + case revokeDocumentLink(documentId: String, token: String) + case resolveDocument(token: String) + case claimDocument(token: String) + } + let kind: Kind +} + +actor StubSharingService: SharingServicing { + + // Per-operation FIFO queues, shared across list/document halves. + private var linksOutcomes: [Result<[ShareLink], Error>] = [] + private var createOutcomes: [Result] = [] + private var revokeOutcomes: [Result] = [] + private var resolveOutcomes: [Result] = [] + private var claimOutcomes: [Result] = [] + + private(set) var recorded: [RecordedSharingCall] = [] + + // MARK: Programmable enqueue helpers + + func enqueueLinks(success value: [ShareLink]) { linksOutcomes.append(.success(value)) } + func enqueueLinks(failure error: Error) { linksOutcomes.append(.failure(error)) } + + func enqueueCreate(success value: ShareLink) { createOutcomes.append(.success(value)) } + func enqueueCreate(failure error: Error) { createOutcomes.append(.failure(error)) } + + func enqueueRevoke(success value: Bool) { revokeOutcomes.append(.success(value)) } + func enqueueRevoke(failure error: Error) { revokeOutcomes.append(.failure(error)) } + + func enqueueResolve(success value: ResolvedShare) { resolveOutcomes.append(.success(value)) } + func enqueueResolve(failure error: Error) { resolveOutcomes.append(.failure(error)) } + + func enqueueClaim(success value: ShareClaim) { claimOutcomes.append(.success(value)) } + func enqueueClaim(failure error: Error) { claimOutcomes.append(.failure(error)) } + + // MARK: SharingServicing — Lists + + func listShareLinks(listId: String) async throws -> [ShareLink] { + recorded.append(.init(kind: .listLinks(listId: listId))) + return try take(&linksOutcomes, label: "listShareLinks") + } + + func createListShareLink(listId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink { + recorded.append(.init(kind: .createListLink(listId: listId, role: role, expiresAt: expiresAt))) + return try take(&createOutcomes, label: "createListShareLink") + } + + func revokeListShareLink(listId: String, token: String) async throws -> Bool { + recorded.append(.init(kind: .revokeListLink(listId: listId, token: token))) + return try take(&revokeOutcomes, label: "revokeListShareLink") + } + + func resolveListShare(token: String) async throws -> ResolvedShare { + recorded.append(.init(kind: .resolveList(token: token))) + return try take(&resolveOutcomes, label: "resolveListShare") + } + + func claimListShare(token: String) async throws -> ShareClaim { + recorded.append(.init(kind: .claimList(token: token))) + return try take(&claimOutcomes, label: "claimListShare") + } + + // MARK: SharingServicing — Documents + + func documentShareLinks(documentId: String) async throws -> [ShareLink] { + recorded.append(.init(kind: .documentLinks(documentId: documentId))) + return try take(&linksOutcomes, label: "documentShareLinks") + } + + func createDocumentShareLink(documentId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink { + recorded.append(.init(kind: .createDocumentLink(documentId: documentId, role: role, expiresAt: expiresAt))) + return try take(&createOutcomes, label: "createDocumentShareLink") + } + + func revokeDocumentShareLink(documentId: String, token: String) async throws -> Bool { + recorded.append(.init(kind: .revokeDocumentLink(documentId: documentId, token: token))) + return try take(&revokeOutcomes, label: "revokeDocumentShareLink") + } + + func resolveDocumentShare(token: String) async throws -> ResolvedShare { + recorded.append(.init(kind: .resolveDocument(token: token))) + return try take(&resolveOutcomes, label: "resolveDocumentShare") + } + + func claimDocumentShare(token: String) async throws -> ShareClaim { + recorded.append(.init(kind: .claimDocument(token: token))) + return try take(&claimOutcomes, label: "claimDocumentShare") + } + + // MARK: - Queue plumbing + + private func take(_ queue: inout [Result], label: String) throws -> T { + guard !queue.isEmpty else { throw StubError.noOutcome(label: label) } + switch queue.removeFirst() { + case .success(let value): return value + case .failure(let error): throw error + } + } + + enum StubError: Error, Equatable { + case noOutcome(label: String) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessage.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessage.swift new file mode 100644 index 0000000..9b9baba --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessage.swift @@ -0,0 +1,106 @@ +import Foundation + +// MARK: - DMFolder + +/// The three per-user DM folders (the-gaps.md G1). Each side of a conversation +/// maintains its own folder membership independently. +public enum DMFolder: String, Sendable, Equatable, Hashable, CaseIterable, Identifiable { + case inbox + case sent + case deleted + + public var id: String { rawValue } + + public var label: String { + switch self { + case .inbox: return "Inbox" + case .sent: return "Sent" + case .deleted: return "Deleted" + } + } +} + +// MARK: - DirectMessage + +/// A private 1:1 message between two mutual followers. +public struct DirectMessage: Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let senderId: String + public let recipientId: String + public let body: String + public let imageURLs: [URL] + public let createdAt: Date + public let readAt: Date? + public let sender: UserSummary? + public let recipient: UserSummary? + + public init( + id: String, + senderId: String, + recipientId: String, + body: String, + imageURLs: [URL] = [], + createdAt: Date, + readAt: Date? = nil, + sender: UserSummary? = nil, + recipient: UserSummary? = nil + ) { + self.id = id + self.senderId = senderId + self.recipientId = recipientId + self.body = body + self.imageURLs = imageURLs + self.createdAt = createdAt + self.readAt = readAt + self.sender = sender + self.recipient = recipient + } + + /// Whether the recipient has read this message. + public var isRead: Bool { readAt != nil } + + /// `true` when `userId` is the sender (used to align bubbles left/right). + public func isOutgoing(currentUserId userId: String) -> Bool { + senderId == userId + } +} + +// MARK: - DMPage + +/// A cursor-paginated folder listing. +public struct DMPage: Sendable, Equatable { + public let messages: [DirectMessage] + public let nextCursor: String? + + public init(messages: [DirectMessage], nextCursor: String? = nil) { + self.messages = messages + self.nextCursor = nextCursor + } + + public static let empty = DMPage(messages: [], nextCursor: nil) +} + +// MARK: - DMThread + +/// A resolved conversation with one other user. +public struct DMThread: Sendable, Equatable { + public let messages: [DirectMessage] + public let otherUser: UserSummary? + public let isMutual: Bool + public let isBlocked: Bool + public let olderCursor: String? + + public init( + messages: [DirectMessage], + otherUser: UserSummary? = nil, + isMutual: Bool = false, + isBlocked: Bool = false, + olderCursor: String? = nil + ) { + self.messages = messages + self.otherUser = otherUser + self.isMutual = isMutual + self.isBlocked = isBlocked + self.olderCursor = olderCursor + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessageMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessageMappers.swift new file mode 100644 index 0000000..92eecec --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DirectMessageMappers.swift @@ -0,0 +1,47 @@ +import Foundation +import InterlinedKit + +// MARK: - Direct Message DTO → domain mapping +// +// Per-group slice of the audit-in-one-place mapper convention (PLAN.md §3). +// Per decision 0003 the App layer never references the kit DTOs — the DM +// service returns `DirectMessage` / `DMPage` / `DMThread`, and this file is the +// one place those cross the boundary. `sender` / `recipient` reuse the existing +// `UserSummary.init(from: UserSummaryDTO)` mapper. + +extension DirectMessage { + public init(from dto: DirectMessageDTO) { + self.init( + id: dto.id, + senderId: dto.senderId, + recipientId: dto.recipientId, + body: dto.body, + imageURLs: (dto.imageUrls ?? []).compactMap(URL.init(string:)), + createdAt: dto.createdAt, + readAt: dto.readAt, + sender: dto.sender.map(UserSummary.init(from:)), + recipient: dto.recipient.map(UserSummary.init(from:)) + ) + } +} + +extension DMPage { + public init(from dto: DMFolderPage) { + self.init( + messages: dto.items.map(DirectMessage.init(from:)), + nextCursor: dto.nextCursor + ) + } +} + +extension DMThread { + public init(from dto: DMThreadResponse) { + self.init( + messages: dto.items.map(DirectMessage.init(from:)), + otherUser: dto.otherUser.map(UserSummary.init(from:)), + isMutual: dto.isMutual ?? false, + isBlocked: dto.isBlocked ?? false, + olderCursor: dto.olderCursor + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplate.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplate.swift new file mode 100644 index 0000000..e5aa3e1 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplate.swift @@ -0,0 +1,196 @@ +import Foundation + +// MARK: - DocumentTemplate + +/// A starter template that seeds a new document's title and Markdown body +/// before it is handed to the normal create flow (feature-gaps.md §1.4). +/// +/// **This is a client-side feature.** The InterlinedList API exposes no +/// documents-templates endpoint (see +/// `InterlinedKit/Endpoints/DocumentsEndpoint.swift` — only sync, document +/// CRUD, image upload, and folder CRUD). A template is therefore nothing more +/// than bundled starter Markdown: the app picks one, seeds a fresh buffer, and +/// routes it through the existing `DocumentsServicing.create` path exactly like +/// a blank document. Nothing about a template survives on the server — once the +/// document is created it is an ordinary document. +/// +/// If the API later grows a real templates endpoint, this type can migrate to a +/// server-backed catalog: replace `builtIn` with a `TemplatesServicing` fetch +/// and keep the same `{ name, summary, bodyMarkdown }` shape so call sites do +/// not change. +public struct DocumentTemplate: Sendable, Equatable, Hashable, Identifiable { + + /// Stable identifier used for selection and diffing. Unique within a + /// catalog (enforced by `DocumentTemplateTests`). + public let id: String + + /// Human-readable name shown in the picker and used as the default title + /// of a document seeded from this template. + public let name: String + + /// One-line description shown under the name in the picker so the user can + /// tell templates apart without opening them. + public let summary: String + + /// The starter Markdown seeded into the new document's `DocumentBody`. + /// May be empty (the Blank template) — that is the canonical + /// "new blank document" behavior. + public let bodyMarkdown: String + + public init(id: String, name: String, summary: String, bodyMarkdown: String) { + self.id = id + self.name = name + self.summary = summary + self.bodyMarkdown = bodyMarkdown + } + + /// The body this template seeds, as a typed `DocumentBody`. Convenience for + /// call sites that already speak `DocumentBody` rather than raw `String`. + public var body: DocumentBody { + DocumentBody(markdown: bodyMarkdown) + } +} + +// MARK: - Built-in catalog + +public extension DocumentTemplate { + + /// The bundled starter catalog. Static and dependency-free so App-layer + /// view models can reference it directly — no composition-root wiring is + /// required (there is no service to inject because there is no endpoint). + /// + /// The first entry (`.blank`) is the identity template: seeding from it is + /// byte-for-byte the same as the existing "New blank document" action. + static let builtIn: [DocumentTemplate] = [ + .blank, + .meetingNotes, + .dailyLog, + .productRequirements + ] + + /// An empty document. Equivalent to today's "New Document" action; kept in + /// the catalog so the picker always offers a "start from scratch" option. + static let blank = DocumentTemplate( + id: "blank", + name: "Blank", + summary: "An empty document to start from scratch.", + bodyMarkdown: "" + ) + + static let meetingNotes = DocumentTemplate( + id: "meeting-notes", + name: "Meeting Notes", + summary: "Agenda, attendees, discussion, and action items.", + bodyMarkdown: """ + # Meeting Notes + + **Date:** \n\ + **Attendees:** \n\ + **Facilitator:** + + ## Agenda + + 1. + 2. + 3. + + ## Discussion + + - + + ## Decisions + + - + + ## Action Items + + - [ ] Owner — task — due date + - [ ] + + ## Follow-up + + - + """ + ) + + static let dailyLog = DocumentTemplate( + id: "daily-log", + name: "Daily Log", + summary: "A running journal for today's plans, progress, and blockers.", + bodyMarkdown: """ + # Daily Log + + **Date:** + + ## Plan for Today + + - [ ] + - [ ] + - [ ] + + ## Progress + + - + + ## Blockers + + - + + ## Notes + + - + + ## Tomorrow + + - + """ + ) + + static let productRequirements = DocumentTemplate( + id: "prd", + name: "Product Requirements", + summary: "A PRD skeleton: problem, goals, scope, and success metrics.", + bodyMarkdown: """ + # Product Requirements: + + **Author:** \n\ + **Status:** Draft \n\ + **Last updated:** + + ## Summary + + One paragraph describing what this is and why it matters. + + ## Problem + + What problem are we solving, and for whom? + + ## Goals + + - + - + + ## Non-Goals + + - + + ## Requirements + + ### Functional + + - + + ### Non-Functional + + - + + ## Open Questions + + - + + ## Success Metrics + + - + """ + ) +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplateRef.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplateRef.swift new file mode 100644 index 0000000..7f03626 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplateRef.swift @@ -0,0 +1,25 @@ +import Foundation +import InterlinedKit + +/// A reference to one of the user's **server-side** template documents +/// (the-gaps.md G12). Distinct from `DocumentTemplate`, which is the app's +/// built-in, client-side starter catalog (Blank / Meeting Notes / …). Server +/// templates are the user's own saved documents in the `_templates` folder and +/// sync across devices. +public struct DocumentTemplateRef: Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let title: String + public let relativePath: String? + + public init(id: String, title: String, relativePath: String? = nil) { + self.id = id + self.title = title + self.relativePath = relativePath + } +} + +extension DocumentTemplateRef { + public init(from dto: DocumentTemplateDTO) { + self.init(id: dto.id, title: dto.title, relativePath: dto.relativePath) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedInTarget.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedInTarget.swift new file mode 100644 index 0000000..865ceea --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/LinkedInTarget.swift @@ -0,0 +1,70 @@ +import Foundation +import InterlinedKit + +// MARK: - LinkedInTarget + +/// A LinkedIn destination the user can cross-post to (the-gaps.md G11a). +public struct LinkedInTarget: Sendable, Equatable, Hashable, Identifiable { + public enum Kind: String, Sendable, Equatable, Hashable { + case personal + case org + case other + } + + public let kind: Kind + public let label: String + public let avatarURL: URL? + public let isEnabled: Bool + + public var id: String { "\(kind.rawValue):\(label)" } + + public init(kind: Kind, label: String, avatarURL: URL? = nil, isEnabled: Bool = false) { + self.kind = kind + self.label = label + self.avatarURL = avatarURL + self.isEnabled = isEnabled + } +} + +extension LinkedInTarget { + public init(from dto: LinkedInTargetDTO) { + let kind: Kind + switch dto.kind.lowercased() { + case "personal": kind = .personal + case "org", "organization": kind = .org + default: kind = .other + } + self.init( + kind: kind, + label: dto.label, + avatarURL: dto.avatarUrl.flatMap(URL.init(string:)), + isEnabled: dto.enabled ?? false + ) + } +} + +// MARK: - LinkedInPostingTargets + +/// The user's LinkedIn posting targets plus whether org pages are unavailable. +public struct LinkedInPostingTargets: Sendable, Equatable { + public let targets: [LinkedInTarget] + /// `true` when the LinkedIn org scope isn't granted (org pages unavailable — + /// G11b, deferred). + public let orgScopeMissing: Bool + + public init(targets: [LinkedInTarget], orgScopeMissing: Bool = false) { + self.targets = targets + self.orgScopeMissing = orgScopeMissing + } + + public static let empty = LinkedInPostingTargets(targets: [], orgScopeMissing: false) +} + +extension LinkedInPostingTargets { + public init(from dto: LinkedInPostingTargetsResponse) { + self.init( + targets: dto.targets.map(LinkedInTarget.init(from:)), + orgScopeMissing: dto.orgScopeMissing ?? false + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListFolder.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListFolder.swift new file mode 100644 index 0000000..85fe949 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ListFolder.swift @@ -0,0 +1,87 @@ +import Foundation +import InterlinedKit + +// MARK: - ListFolder + +/// A folder that organizes the current user's lists (the-gaps.md G6). Nesting +/// is expressed via `parentId`; the App rebuilds the tree with `ListFolder.tree`. +public struct ListFolder: Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let name: String + public let parentId: String? + public let createdAt: Date? + public let updatedAt: Date? + + public init( + id: String, + name: String, + parentId: String? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.name = name + self.parentId = parentId + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +extension ListFolder { + /// Maps a `ListFolderDTO` to the domain value (decision 0003 boundary). + public init(from dto: ListFolderDTO) { + self.init( + id: dto.id, + name: dto.name, + parentId: dto.parentId, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt + ) + } +} + +// MARK: - ListFolderNode (tree) + +/// A node in the assembled folder tree. `children` are ordered as they appear +/// in the source array. +public struct ListFolderNode: Sendable, Equatable, Hashable, Identifiable { + public let folder: ListFolder + public let children: [ListFolderNode] + + public var id: String { folder.id } + public var name: String { folder.name } + + public init(folder: ListFolder, children: [ListFolderNode] = []) { + self.folder = folder + self.children = children + } +} + +extension ListFolder { + /// Assembles a flat folder array into a root-level tree. A folder whose + /// `parentId` is `nil` — or points to a folder not present in the array — + /// becomes a root (so the caller never loses a folder to a dangling + /// parent). Cycles are broken defensively: a node is only ever attached + /// once, so a folder that (transitively) parents itself lands at root. + public static func tree(from folders: [ListFolder]) -> [ListFolderNode] { + let byId = Dictionary(folders.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var childIds: [String: [String]] = [:] + var rootIds: [String] = [] + for folder in folders { + if let parentId = folder.parentId, byId[parentId] != nil, parentId != folder.id { + childIds[parentId, default: []].append(folder.id) + } else { + rootIds.append(folder.id) + } + } + + func build(_ id: String, visited: Set) -> ListFolderNode? { + guard let folder = byId[id], !visited.contains(id) else { return nil } + let nextVisited = visited.union([id]) + let children = (childIds[id] ?? []).compactMap { build($0, visited: nextVisited) } + return ListFolderNode(folder: folder, children: children) + } + + return rootIds.compactMap { build($0, visited: []) } + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift index 60b81b6..d52b09e 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift @@ -66,7 +66,12 @@ extension Message { Repost.message(Message(from: box.message)) }, scheduledAt: dto.scheduledAt, - crossPostResults: (dto.crossPosts ?? []).map(CrossPostResult.init(from:)) + crossPostResults: (dto.crossPosts ?? []).map(CrossPostResult.init(from:)), + // feature-gaps §1.5: thread server-rendered link previews through. + // `compactMap` drops entries whose `url` string will not parse so a + // `LinkPreview` always carries a usable `URL`. Not persisted in + // SwiftData — re-derived from the DTO on every load (see Message). + linkPreviews: (dto.linkMetadata?.links ?? []).compactMap(LinkPreview.init(from:)) ) } } @@ -105,6 +110,27 @@ extension CrossPostResult { } } +extension LinkPreview { + /// Maps a single server-rendered link preview (feature-gaps §1.5). + /// + /// Returns `nil` when the wire `url` string will not parse, so the caller's + /// `compactMap` drops it — a `LinkPreview` is only ever constructed with a + /// usable `URL`. The `imageUrl` string is parsed with the same tolerance and + /// silently dropped when malformed (the card degrades to no thumbnail rather + /// than failing to render). + public init?(from dto: LinkPreviewDTO) { + guard let url = URL(string: dto.url) else { return nil } + self.init( + url: url, + platform: dto.platform, + fetchStatus: dto.fetchStatus, + title: dto.title, + description: dto.description, + imageURL: dto.imageUrl.flatMap(URL.init(string:)) + ) + } +} + extension UserSearchResult { /// Maps from the search / lookup DTO. Avatar string is parsed into a URL and /// silently dropped if malformed — the UI always has the username as fallback. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift index 8095344..f43ae6b 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift @@ -48,6 +48,18 @@ public struct Message: Sendable, Equatable, Identifiable { /// return cross-post data for this response. public let crossPostResults: [CrossPostResult] + /// Server-rendered rich link previews for URLs found in the body + /// (feature-gaps §1.5). Empty when the message contains no links or the + /// server did not resolve any preview metadata for this response. + /// + /// SCOPE DECISION (feature-gaps §1.5): link previews are treated as a + /// fetch-time / UI concern and are **not** persisted in SwiftData + /// (`MessageRecord`). They are re-derived from the DTO on every load, + /// refreshing naturally on the next fetch. This deliberately avoids a + /// SwiftData schema migration; the trade-off is that previews are absent + /// when a row is rendered purely from the local cache before a refresh. + public let linkPreviews: [LinkPreview] + public init( id: String, author: UserSummary, @@ -63,7 +75,8 @@ public struct Message: Sendable, Equatable, Identifiable { parentID: String? = nil, repost: Repost? = nil, scheduledAt: Date? = nil, - crossPostResults: [CrossPostResult] = [] + crossPostResults: [CrossPostResult] = [], + linkPreviews: [LinkPreview] = [] ) { self.id = id self.author = author @@ -80,6 +93,7 @@ public struct Message: Sendable, Equatable, Identifiable { self.repost = repost self.scheduledAt = scheduledAt self.crossPostResults = crossPostResults + self.linkPreviews = linkPreviews } } @@ -127,6 +141,84 @@ public struct CrossPostResult: Sendable, Equatable { } } +// MARK: - LinkPreview (feature-gaps §1.5) + +/// A server-rendered rich link preview attached to a message. +/// +/// Maps from `LinkPreviewDTO`. The wire `url` string is coerced to a `URL` +/// during mapping and entries whose `url` will not parse are dropped, so a +/// `LinkPreview` always carries a usable `url`. The remaining fields mirror the +/// server's Open Graph resolution and stay optional because the server may not +/// have finished (or succeeded at) fetching them. +public struct LinkPreview: Sendable, Equatable, Identifiable { + /// The resolved link. Doubles as the stable identity for `ForEach`. + public let url: URL + /// Source platform label the server attached (e.g. "youtube", "github"), + /// when it recognised one. + public let platform: String? + /// The server's fetch-state string for this preview. The exact vocabulary + /// (which value means "ready") is **not documented** in the API reference + /// as of 2026-07-18 — see `isFetchStatusReady`. Kept as the raw string so + /// no information is lost and the client stays forward-compatible. + public let fetchStatus: String? + public let title: String? + public let description: String? + public let imageURL: URL? + + public var id: URL { url } + + public init( + url: URL, + platform: String? = nil, + fetchStatus: String? = nil, + title: String? = nil, + description: String? = nil, + imageURL: URL? = nil + ) { + self.url = url + self.platform = platform + self.fetchStatus = fetchStatus + self.title = title + self.description = description + self.imageURL = imageURL + } + + /// Whether `fetchStatus` names a state the client recognises as a completed, + /// successful fetch. + /// + /// NOTE (backend question, feature-gaps §1.5): the API reference does not + /// document the `fetchStatus` vocabulary, so we cannot be certain which + /// string means "ready". This matches a small, case-insensitive set of the + /// conventional success tokens. It is intentionally **not** the sole gate on + /// rendering — `isRenderable` also renders whenever a title or image is + /// present — so an unknown-but-successful status string never hides an + /// otherwise-complete card. + public var isFetchStatusReady: Bool { + guard let status = fetchStatus?.lowercased() else { return false } + return ["ready", "success", "succeeded", "ok", "complete", "completed", "fetched"].contains(status) + } + + /// Whether this preview carries enough resolved metadata to be worth + /// rendering as a card. True when the server reports a ready fetch status + /// OR when a human-meaningful field (title or image) is present. A bare URL + /// with no resolved metadata returns `false` — the UI degrades to nothing + /// (or a minimal chip) rather than an empty card. + public var isRenderable: Bool { + if isFetchStatusReady { return true } + if let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } + if imageURL != nil { return true } + return false + } + + /// The host component shown as the card subtitle (e.g. "github.com"), + /// stripped of a leading `www.`. Falls back to the full URL string when the + /// URL has no host. + public var displayHost: String { + guard let host = url.host else { return url.absoluteString } + return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + } +} + /// One page of a timeline read: the messages plus the cursor needed to ask for /// the next page. Maps the kit's `PaginationInfo` envelope into the two values /// the UI's infinite scroll actually needs. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Moderation.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Moderation.swift new file mode 100644 index 0000000..a62b473 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Moderation.swift @@ -0,0 +1,53 @@ +import Foundation + +// MARK: - ModeratedUser + +/// A user the current account has blocked or muted (the-gaps.md G2). The App +/// layer renders these in the Settings "Blocked & Muted" pane and uses the +/// membership to filter timelines / threads / DM eligibility. +public struct ModeratedUser: Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let username: String? + public let displayName: String? + public let avatarURL: URL? + + public init(id: String, username: String? = nil, displayName: String? = nil, avatarURL: URL? = nil) { + self.id = id + self.username = username + self.displayName = displayName + self.avatarURL = avatarURL + } + + /// `@handle` when a username is present, otherwise the id. + public var handle: String { username.map { "@\($0)" } ?? id } + + /// Best available display name. + public var name: String { displayName ?? username ?? id } +} + +// MARK: - ReportReason + +/// The reason attached to a user or message report. Values match the live API +/// contract (`harassment|spam|misinformation|inappropriate|other`). The domain +/// layer owns this enum so the App picks from a typed, exhaustive list and the +/// kit only ever sees the validated `rawValue`. +public enum ReportReason: String, Sendable, Equatable, Hashable, CaseIterable, Identifiable { + case harassment + case spam + case misinformation + case inappropriate + case other + + public var id: String { rawValue } + + /// Human-facing label for the report reason picker. + public var label: String { + switch self { + case .harassment: return "Harassment" + case .spam: return "Spam" + case .misinformation: return "Misinformation" + case .inappropriate: return "Inappropriate content" + case .other: return "Other" + } + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ModerationMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ModerationMappers.swift new file mode 100644 index 0000000..abeec65 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ModerationMappers.swift @@ -0,0 +1,22 @@ +import Foundation +import InterlinedKit + +// MARK: - Moderation DTO → domain mapping +// +// Per-group slice of the audit-in-one-place mapper convention (PLAN.md §3). +// Per decision 0003 the App layer never references the kit DTOs — this file is +// the one place the block/mute list rows cross the boundary into `ModeratedUser`. + +extension ModeratedUser { + + /// Maps a `ModeratedUserDTO` (block/mute list row) to the domain value. + /// Tolerant of missing fields: only `id` is guaranteed on the wire. + public init(from dto: ModeratedUserDTO) { + self.init( + id: dto.id, + username: dto.username, + displayName: dto.displayName, + avatarURL: dto.avatar.flatMap(URL.init(string:)) + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift index 186d5e8..788ae21 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/ProfileMappers.swift @@ -51,6 +51,28 @@ extension UserProfile { ) } + /// Builds a public profile from the dedicated `GET /api/users/{username}` + /// endpoint (the-gaps.md D2). Unlike the decision-0002 embedded-author + /// fallback, this source is rich: bio, join date, private flag, and + /// follower/following counts all come straight from the payload, so no + /// separate `counts(of:)` stitch is required. + public init(from dto: PublicProfileDTO) { + 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: dto.followerCount, + followingCount: dto.followingCount, + isPrivate: dto.isPrivate ?? false, + joinedAt: dto.joinedAt + ) + } + /// 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. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift index d91be5f..f7ff25f 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift @@ -38,11 +38,42 @@ public enum SchemaFieldType: String, Sendable, Equatable, Hashable, CaseIterable /// status not yet documented (prompts file 2.2). case email + /// A single-choice value drawn from an ordered option set carried by the + /// owning `SchemaField.enumValues`. The DSL declares the options inline: + /// `Priority:select(low|med|high)`. Wire shape (row data): JSON string — + /// the chosen option's raw text. The option list itself lives in the + /// schema DSL string, not in row data. + /// + /// The `select(...)` option-encoding grammar is a **client convention** + /// introduced here: the API's `markdown` token is documented at + /// interlinedlist.com/help/api/lists, but the `select` option syntax is + /// not enumerated there (the docs note "a few others" exist without + /// pinning the grammar). If the server names the token or delimiter + /// differently, this is the single place that changes — see the + /// serializer/parser in `SchemaDSL`. + case select + + /// Long-form Markdown text. Parses like `text` (no options); rendered as + /// an editable multiline field with a Markdown preview in the UI, reusing + /// the same `Textual` renderer as Documents. Wire shape: JSON string + /// carrying raw Markdown source. Documented token at + /// interlinedlist.com/help/api/lists. + case markdown + /// The canonical DSL token for this type ("text", "number", …). The /// raw value is the wire token by construction; this alias exists so /// the parser/serializer can read as intent rather than implementation. + /// + /// Note this is the *bare* type token only. A `select` column emits its + /// options separately (`select(a|b|c)`); the option list is not part of + /// `dslToken` — see `SchemaDSL.serialize`. public var dslToken: String { rawValue } + /// Whether this type carries an inline option set in the DSL + /// (`type(a|b|c)`). Only `select` does today; centralised here so the + /// parser, serializer, and editor all agree on which types need options. + public var carriesOptions: Bool { self == .select } + /// Maps a DSL token to a type, case-insensitively. Returns `nil` for any /// token outside the closed set — the parser surfaces that as /// `SchemaDSLError.unknownType`. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Sharing.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Sharing.swift new file mode 100644 index 0000000..30cf768 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Sharing.swift @@ -0,0 +1,148 @@ +import Foundation +import InterlinedKit + +// MARK: - ShareRole + +/// A share grant's capability level (the-gaps.md G3). Values match the live API +/// (`watcher|collaborator|manager`); the UI labels follow the docs +/// (Viewer/Editor/Admin). +public enum ShareRole: String, Sendable, Equatable, Hashable, CaseIterable, Identifiable { + case watcher + case collaborator + case manager + + public var id: String { rawValue } + + /// UI-facing label. + public var label: String { + switch self { + case .watcher: return "Viewer" + case .collaborator: return "Editor" + case .manager: return "Admin" + } + } + + /// Viewers are read-only; editors and admins can modify content. + public var canEdit: Bool { self != .watcher } + /// Only admins can edit settings/schema and delete. + public var canManage: Bool { self == .manager } +} + +// MARK: - ShareLink + +/// A tokenized share link for a list or document. +public struct ShareLink: Sendable, Equatable, Hashable, Identifiable { + public let token: String + public let url: URL? + public let role: ShareRole + public let expiresAt: Date? + public let createdAt: Date? + public let revokedAt: Date? + + public var id: String { token } + + /// `true` once the link has been revoked (the server stops resolving it). + public var isRevoked: Bool { revokedAt != nil } + + public init( + token: String, + url: URL? = nil, + role: ShareRole, + expiresAt: Date? = nil, + createdAt: Date? = nil, + revokedAt: Date? = nil + ) { + self.token = token + self.url = url + self.role = role + self.expiresAt = expiresAt + self.createdAt = createdAt + self.revokedAt = revokedAt + } +} + +extension ShareLink { + public init(from dto: ShareLinkDTO) { + self.init( + token: dto.token, + url: dto.url.flatMap(URL.init(string:)), + role: ShareRole(rawValue: dto.role) ?? .watcher, + expiresAt: dto.expiresAt, + createdAt: dto.createdAt, + revokedAt: dto.revokedAt + ) + } +} + +// MARK: - ResolvedShare + +/// The result of resolving a share token — the role it grants, whether the +/// caller can/must claim it, and a lightweight view of the shared resource. +public struct ResolvedShare: Sendable, Equatable { + public enum Resource: Sendable, Equatable { + case list(id: String, title: String, description: String?, isPublic: Bool) + case document(id: String, title: String, isPublic: Bool) + + public var id: String { + switch self { + case .list(let id, _, _, _): return id + case .document(let id, _, _): return id + } + } + public var title: String { + switch self { + case .list(_, let title, _, _): return title + case .document(_, let title, _): return title + } + } + } + + public let role: ShareRole + public let canClaim: Bool + public let needsAuth: Bool + public let resource: Resource? + + public init(role: ShareRole, canClaim: Bool, needsAuth: Bool, resource: Resource?) { + self.role = role + self.canClaim = canClaim + self.needsAuth = needsAuth + self.resource = resource + } +} + +extension ResolvedShare { + public init(from dto: ResolvedListShareDTO) { + self.init( + role: ShareRole(rawValue: dto.role) ?? .watcher, + canClaim: dto.canClaim ?? false, + needsAuth: dto.needsAuth ?? false, + resource: dto.list.map { + .list(id: $0.id, title: $0.title, description: $0.description, isPublic: $0.isPublic ?? false) + } + ) + } + + public init(from dto: ResolvedDocumentShareDTO) { + self.init( + role: ShareRole(rawValue: dto.role) ?? .watcher, + canClaim: dto.canClaim ?? false, + needsAuth: dto.needsAuth ?? false, + resource: dto.document.map { + .document(id: $0.id, title: $0.title, isPublic: $0.isPublic ?? false) + } + ) + } +} + +// MARK: - ShareClaim + +/// The result of claiming an edit/admin share link. +public struct ShareClaim: Sendable, Equatable { + public let resourceId: String? + public let role: ShareRole? + + public init(resourceId: String?, role: ShareRole?) { + self.resourceId = resourceId + self.role = role + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift index 947b50a..ddf01c4 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift @@ -28,6 +28,16 @@ public enum SchemaDSLError: Error, Sendable, Equatable { /// Two columns share the same name — schemas forbid duplicates so the /// cell map (`[String: ListCellValue]`) stays unambiguous. case duplicateFieldName(String) + + /// A `select` column declared an empty option list — `select()` or + /// `select` with no options. A single-choice column needs at least one + /// option to be meaningful. The field name is included for the toast. + case emptySelectOptions(field: String) + + /// A `select` column repeated the same option — options must be a set so + /// the picker never renders two identical rows. The offending option is + /// included so the editor can highlight it. + case duplicateSelectOption(field: String, option: String) } extension SchemaDSLError: LocalizedError, CustomStringConvertible { @@ -43,6 +53,10 @@ extension SchemaDSLError: LocalizedError, CustomStringConvertible { return "Schema type \"\(raw)\" is not a recognised type." case .duplicateFieldName(let name): return "Schema field \"\(name)\" is declared more than once." + case .emptySelectOptions(let field): + return "Schema field \"\(field)\" is a select but declares no options." + case .duplicateSelectOption(let field, let option): + return "Schema field \"\(field)\" repeats the select option \"\(option)\"." } } } @@ -52,15 +66,30 @@ extension SchemaDSLError: LocalizedError, CustomStringConvertible { /// Parser and serializer for the InterlinedList schema DSL /// (`"Title:text, Year:number, Released:date"`). /// -/// ## DSL grammar (M3 starter set — see prompts file item 2.2) +/// ## DSL grammar (M3 starter set + §1.1 select/markdown) /// /// ``` /// schema := field ( "," field )* -/// field := name ":" type +/// field := name ":" type [ "(" options ")" ] /// name := one-or-more characters, no comma, no colon, trimmed /// type := one of the SchemaFieldType DSL tokens +/// options := option ( "|" option )* -- select only +/// option := one-or-more characters, no pipe, no parens, trimmed /// ``` /// +/// The optional `"(" options ")"` suffix applies to `select` only +/// (`Priority:select(low|med|high)`). The option list is `|`-delimited so it +/// carries no top-level commas — the plain comma split still separates +/// fields. Empty option lists (`select()`) and duplicate options are typed +/// errors (`.emptySelectOptions` / `.duplicateSelectOption`). Non-`select` +/// types reject a trailing `(...)` as a syntax error. `markdown` parses like +/// `text` (no options). +/// +/// The `select(...)` option grammar is a **client convention** — the API +/// documents the `markdown` token but not `select`'s option syntax (see +/// `SchemaFieldType.select`). It round-trips only through this file, so it is +/// the one place to adjust if the server pins a different token/delimiter. +/// /// Whitespace around commas and colons is tolerated (and stripped) on parse, /// and re-emitted in canonical form by `serialize` so that `parse → serialize` /// is **normalising** rather than verbatim. The round-trip guarantee is @@ -109,25 +138,111 @@ public enum SchemaDSL { throw SchemaDSLError.invalidFieldSyntax(rawField: token) } let name = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let typeToken = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, !typeToken.isEmpty else { + let typeSpec = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !typeSpec.isEmpty else { throw SchemaDSLError.invalidFieldSyntax(rawField: token) } + + // Split the type spec into its bare token and an optional + // `(...)` option suffix. `select(low|med|high)` → ("select", + // "low|med|high"); a bare `text` → ("text", nil). + let (typeToken, optionsBody) = try Self.splitTypeSpec(typeSpec, rawField: token) + guard let type = SchemaFieldType(dslToken: typeToken) else { throw SchemaDSLError.unknownType(rawType: typeToken) } + + // Option suffixes are only valid for option-carrying types + // (`select`). A trailing `(...)` on any other type is a syntax + // error so `text(a|b)` does not silently drop the options. + let enumValues = try Self.resolveOptions( + optionsBody, + for: type, + fieldName: name, + rawField: token + ) + // Duplicate-name check is case-sensitive — column names are // case-sensitive at the row-data layer too. guard !seenNames.contains(name) else { throw SchemaDSLError.duplicateFieldName(name) } seenNames.insert(name) - fields.append(SchemaField(name: name, type: type)) + fields.append(SchemaField(name: name, type: type, enumValues: enumValues)) } return ListSchema(fields: fields) } + // MARK: Type-spec + options parsing + + /// Splits a type spec into its bare token and optional `(...)` body. + /// + /// `"select(low|med|high)"` → `("select", "low|med|high")`; + /// `"text"` → `("text", nil)`. A `(` with no matching trailing `)`, + /// or trailing characters after the `)`, is a syntax error. + private static func splitTypeSpec( + _ spec: String, + rawField: String + ) throws -> (token: String, optionsBody: String?) { + guard let openIndex = spec.firstIndex(of: "(") else { + // No paren group: the whole spec is the type token. A stray + // closing paren with no opener is malformed. + guard !spec.contains(")") else { + throw SchemaDSLError.invalidFieldSyntax(rawField: rawField) + } + return (spec, nil) + } + // Must close with `)` as the final character, with nothing after it. + guard spec.hasSuffix(")") else { + throw SchemaDSLError.invalidFieldSyntax(rawField: rawField) + } + let token = String(spec[spec.startIndex.. [String]? { + guard type.carriesOptions else { + // A `(...)` suffix on a non-option type is a syntax error. + if body != nil { + throw SchemaDSLError.invalidFieldSyntax(rawField: rawField) + } + return nil + } + // `select` requires a `(...)` body with at least one option. + guard let body else { + throw SchemaDSLError.emptySelectOptions(field: fieldName) + } + let options = body + .split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + // Reject empty option lists and any blank option (e.g. `a||b`). + guard !options.isEmpty, !options.contains(where: \.isEmpty) else { + throw SchemaDSLError.emptySelectOptions(field: fieldName) + } + var seen: Set = [] + for option in options where !seen.insert(option).inserted { + throw SchemaDSLError.duplicateSelectOption(field: fieldName, option: option) + } + return options + } + // MARK: Serialize /// Serializes a `ListSchema` back to the canonical DSL form @@ -143,7 +258,18 @@ public enum SchemaDSL { /// wire never carries it. public static func serialize(_ schema: ListSchema) -> String { schema.fields - .map { "\($0.name):\($0.type.dslToken)" } + .map { field in + let base = "\(field.name):\(field.type.dslToken)" + // `select` re-emits its ordered option set inline. A select + // with no options is a malformed in-memory value the editor + // should never produce; guard defensively by omitting the + // `()` so a reparse fails loudly rather than silently. + guard field.type.carriesOptions, + let options = field.enumValues, !options.isEmpty else { + return base + } + return "\(base)(\(options.joined(separator: "|")))" + } .joined(separator: ", ") } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift new file mode 100644 index 0000000..5bd6187 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift @@ -0,0 +1,111 @@ +import Foundation +import InterlinedKit + +// MARK: - DirectMessagesError + +public enum DirectMessagesError: Error, Sendable, Equatable { + /// Attempted to send a message with no text and no images. + case emptyMessage +} + +extension DirectMessagesError: LocalizedError, CustomStringConvertible { + public var errorDescription: String? { description } + public var description: String { + switch self { + case .emptyMessage: return "A message needs text or an image before it can be sent." + } + } +} + +// MARK: - DirectMessagesServicing + +/// The Direct Messages surface the App layer codes against (the-gaps.md G1) — +/// list folders, load and poll a thread, send, discover eligible recipients, +/// the unread badge count, and per-side read/trash/restore. +/// +/// Free tier — no subscriber gate. Eligibility (mutual-follow, not-blocked) is +/// enforced by the server; the service surfaces the server's error. `send` +/// rejects an empty message client-side before any request. Read/trash/restore +/// are fire-and-forget (`sendVoid`). +public protocol DirectMessagesServicing: Sendable { + func folder(_ folder: DMFolder, cursor: String?) async throws -> DMPage + func thread(username: String, cursor: String?) async throws -> DMThread + func threadUpdates(username: String, since: String?) async throws -> DMThread + func send(recipientId: String, body: String, imageURLs: [String]) async throws -> DirectMessage + func recipients() async throws -> [UserSummary] + func unreadCount() async throws -> Int + func markRead(id: String) async throws + func trash(id: String) async throws + func restore(id: String) async throws +} + +public extension DirectMessagesServicing { + func folder(_ folder: DMFolder = .inbox) async throws -> DMPage { + try await self.folder(folder, cursor: nil) + } + func thread(username: String) async throws -> DMThread { + try await thread(username: username, cursor: nil) + } + func send(recipientId: String, body: String) async throws -> DirectMessage { + try await send(recipientId: recipientId, body: body, imageURLs: []) + } +} + +// MARK: - DirectMessagesService + +public final class DirectMessagesService: DirectMessagesServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + public func folder(_ folder: DMFolder, cursor: String?) async throws -> DMPage { + let dto = try await api.send(DirectMessages.folder(folder.rawValue, cursor: cursor)) + return DMPage(from: dto) + } + + public func thread(username: String, cursor: String?) async throws -> DMThread { + let dto = try await api.send(DirectMessages.thread(username: username, cursor: cursor)) + return DMThread(from: dto) + } + + public func threadUpdates(username: String, since: String?) async throws -> DMThread { + let dto = try await api.send(DirectMessages.threadUpdates(username: username, since: since)) + return DMThread(from: dto) + } + + public func send(recipientId: String, body: String, imageURLs: [String]) async throws -> DirectMessage { + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !imageURLs.isEmpty else { throw DirectMessagesError.emptyMessage } + let request = SendDirectMessageRequest( + recipientId: recipientId, + body: trimmed, + imageUrls: imageURLs.isEmpty ? nil : imageURLs + ) + let response = try await api.send(DirectMessages.send(request)) + return DirectMessage(from: response.message) + } + + public func recipients() async throws -> [UserSummary] { + let dto = try await api.send(DirectMessages.recipients()) + return dto.recipients.map(UserSummary.init(from:)) + } + + public func unreadCount() async throws -> Int { + try await api.send(DirectMessages.unreadCount()).count + } + + public func markRead(id: String) async throws { + try await api.sendVoid(DirectMessages.markRead(id: id)) + } + + public func trash(id: String) async throws { + try await api.sendVoid(DirectMessages.trash(id: id)) + } + + public func restore(id: String) async throws { + try await api.sendVoid(DirectMessages.restore(id: id)) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentTemplatesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentTemplatesService.swift new file mode 100644 index 0000000..39f7760 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentTemplatesService.swift @@ -0,0 +1,43 @@ +import Foundation +import InterlinedKit + +// MARK: - DocumentTemplatesServicing + +/// The server document-templates surface (the-gaps.md G12) — list the user's +/// saved templates, create a document from one, and seed the default starter +/// set. Kept as its own small service (rather than extending `DocumentsService`) +/// so no existing `DocumentsServicing` conformance changes. +/// +/// `createFromTemplate` returns `Void`: the endpoint replies `201` with an empty +/// body, so the App layer reloads the documents list to surface the new document. +public protocol DocumentTemplatesServicing: Sendable { + func templates() async throws -> [DocumentTemplateRef] + func createFromTemplate(templateDocumentId: String) async throws + func seedDefaultTemplates() async throws +} + +// MARK: - DocumentTemplatesService + +public final class DocumentTemplatesService: DocumentTemplatesServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + public func templates() async throws -> [DocumentTemplateRef] { + let dto = try await api.send(Documents.templates()) + return dto.templates.map(DocumentTemplateRef.init(from:)) + } + + public func createFromTemplate(templateDocumentId: String) async throws { + try await api.sendVoid( + Documents.createFromTemplate(CreateFromTemplateRequest(templateDocumentId: templateDocumentId)) + ) + } + + public func seedDefaultTemplates() async throws { + try await api.sendVoid(Documents.seedDefaultTemplates()) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/LinkedInService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/LinkedInService.swift new file mode 100644 index 0000000..1978ae4 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/LinkedInService.swift @@ -0,0 +1,28 @@ +import Foundation +import InterlinedKit + +// MARK: - LinkedInServicing + +/// The LinkedIn posting-targets surface (the-gaps.md G11a) — read the user's +/// available cross-post destinations (personal profile + any org pages) so the +/// composer can offer a target picker. Read-only for now; the enable/sync +/// writes (`PUT /api/linkedin/posting-targets`, `POST /api/linkedin/sync-pages`) +/// and org pages (G11b) are deferred. +public protocol LinkedInServicing: Sendable { + func postingTargets() async throws -> LinkedInPostingTargets +} + +// MARK: - LinkedInService + +public final class LinkedInService: LinkedInServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + public func postingTargets() async throws -> LinkedInPostingTargets { + LinkedInPostingTargets(from: try await api.send(LinkedIn.postingTargets())) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListFoldersService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListFoldersService.swift new file mode 100644 index 0000000..50463ab --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListFoldersService.swift @@ -0,0 +1,103 @@ +import Foundation +import InterlinedKit + +// MARK: - ListFoldersError + +public enum ListFoldersError: Error, Sendable, Equatable { + /// Creating a folder requires an active subscription. Raised before any + /// HTTP call when `EntitlementsService.isSubscriber == false` (folders are + /// explicitly subscriber-only per the live docs, unlike the permissive + /// list-management gate). + case subscriberRequired + /// The folder name was empty or exceeded 80 characters. + case invalidName +} + +extension ListFoldersError: LocalizedError, CustomStringConvertible { + public var errorDescription: String? { description } + public var description: String { + switch self { + case .subscriberRequired: return "Creating list folders requires an active subscription." + case .invalidName: return "A folder name must be 1–80 characters." + } + } +} + +// MARK: - ListFoldersServicing + +/// The list-folders surface the App layer codes against (the-gaps.md G6) — read +/// the folder tree, create (subscriber-gated), rename, move, and delete folders. +/// +/// Follows the domain-service DI shape and mirrors `ListsService`'s entitlement +/// seam: it consults `EntitlementsService` before a gated write and throws +/// `ListFoldersError.subscriberRequired` on a non-subscriber, before any HTTP. +public protocol ListFoldersServicing: Sendable { + func folders() async throws -> [ListFolder] + func tree() async throws -> [ListFolderNode] + func create(name: String, parentId: String?) async throws -> ListFolder + func rename(id: String, name: String) async throws -> ListFolder + func move(id: String, toParent parentId: String?) async throws -> ListFolder + func delete(id: String) async throws +} + +public extension ListFoldersServicing { + func create(name: String) async throws -> ListFolder { + try await create(name: name, parentId: nil) + } +} + +// MARK: - ListFoldersService + +public final class ListFoldersService: ListFoldersServicing { + + private let api: APIClientProtocol + private let entitlements: EntitlementsService + + public init( + api: APIClientProtocol, + entitlements: EntitlementsService = EntitlementsService(customerStatus: .free) + ) { + self.api = api + self.entitlements = entitlements + } + + public func folders() async throws -> [ListFolder] { + let dto = try await api.send(ListFolders.list()) + return dto.folders.map(ListFolder.init(from:)) + } + + public func tree() async throws -> [ListFolderNode] { + ListFolder.tree(from: try await folders()) + } + + public func create(name: String, parentId: String?) async throws -> ListFolder { + guard entitlements.isSubscriber else { throw ListFoldersError.subscriberRequired } + let clean = try Self.validate(name) + let dto = try await api.send(ListFolders.create(CreateListFolderRequest(name: clean, parentId: parentId))) + return ListFolder(from: dto) + } + + public func rename(id: String, name: String) async throws -> ListFolder { + let clean = try Self.validate(name) + let dto = try await api.send(ListFolders.update(id: id, UpdateListFolderRequest(name: clean))) + return ListFolder(from: dto) + } + + public func move(id: String, toParent parentId: String?) async throws -> ListFolder { + let dto = try await api.send(ListFolders.update(id: id, UpdateListFolderRequest(parentId: parentId))) + return ListFolder(from: dto) + } + + public func delete(id: String) async throws { + try await api.sendVoid(ListFolders.delete(id: id)) + } + + // MARK: - Helpers + + /// Trims and range-checks a folder name (1–80 chars per the API contract). + private static func validate(_ name: String) throws -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 80 else { throw ListFoldersError.invalidName } + return trimmed + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MarkdownExporter.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MarkdownExporter.swift new file mode 100644 index 0000000..bb28386 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MarkdownExporter.swift @@ -0,0 +1,198 @@ +import Foundation + +/// Renders domain models to Markdown for the "Markdown export" data-portability +/// feature advertised on interlinedlist.com (feature-gaps.md §1.3 — "Markdown +/// export for lists, documents, and message threads with structured table +/// conversion"). +/// +/// **Why this is a client-side renderer.** The `/api/exports/*` endpoints return +/// CSV only — there is no Markdown format on the wire and no per-document / +/// per-thread export endpoint (see `feature-blockages.md` BE-1). So the app +/// composes Markdown itself from already-fetched domain models. This type is the +/// reusable engine every entry point calls; it is a pure value transformer with +/// no I/O, so it is exhaustively unit-testable and free of `Date.now`-style +/// nondeterminism (callers pass the dates that are already on the models). +/// +/// The three surfaces mirror the three things the web app can export: +/// - `markdown(for:)` — a single long-form document +/// - `markdown(forThreadRoot:replies:)` — a message and its replies +/// - `markdown(forList:)` — a structured list as a Markdown table +public struct MarkdownExporter: Sendable { + + public init() {} + + /// ISO-8601 timestamps keep the output stable and locale-independent, which + /// matters for deterministic tests and for diffable exports. Uses the + /// value-type `ISO8601FormatStyle` (GMT, internet date-time) rather than a + /// stored `ISO8601DateFormatter` — the latter is a non-`Sendable` reference + /// type and cannot live inside this `Sendable` struct. + private func timestamp(_ date: Date) -> String { + date.ISO8601Format() + } + + // MARK: - Documents + + /// Renders a long-form document as Markdown: an H1 title followed by the + /// document body (which is already Markdown source). The body is emitted + /// verbatim — documents author Markdown directly, so no escaping is applied. + public func markdown(for document: Document) -> String { + var out = "# \(document.title.isEmpty ? "Untitled" : document.title)\n" + let body = document.body.markdown.trimmingCharacters(in: .whitespacesAndNewlines) + if !body.isEmpty { + out += "\n\(body)\n" + } + return out + } + + // MARK: - Threads + + /// Renders a message thread: the root post, then its replies in ascending + /// creation order as block quotes. Replies are rendered flat (sorted by + /// `createdAt`) rather than nested by `parentID`; deep-nesting is a later + /// refinement noted in feature-gaps.md. + public func markdown(forThreadRoot root: Message, replies: [Message]) -> String { + var out = "# Thread\n\n" + out += renderPost(root) + + let ordered = replies.sorted { $0.createdAt < $1.createdAt } + if !ordered.isEmpty { + out += "\n---\n\n## Replies\n\n" + for reply in ordered { + out += renderReply(reply) + } + } + return out + } + + /// The root post: bold author handle + timestamp header, then the body. + private func renderPost(_ message: Message) -> String { + var block = "**@\(message.author.username)** · \(timestamp(message.createdAt))\n\n" + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + block += "\(text)\n" + } + return block + } + + /// A reply, rendered as a Markdown block quote so nesting reads visually. + private func renderReply(_ message: Message) -> String { + let header = "> **@\(message.author.username)** · \(timestamp(message.createdAt))" + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + let quotedBody = text + .split(separator: "\n", omittingEmptySubsequences: false) + .map { "> \($0)" } + .joined(separator: "\n") + if text.isEmpty { + return "\(header)\n\n" + } + return "\(header)\n>\n\(quotedBody)\n\n" + } + + // MARK: - Lists (structured table conversion) + + /// Input bundle for a list export. Decoupled from `ListDetail` / `OwnedList` + /// so both public and owned lists render through the same path. + public struct ListInput: Sendable, Equatable { + public let title: String + public let description: String? + /// The schema DSL string (e.g. `"Title:text, Year:number"`). Used only + /// to derive the canonical column order; parsing beyond the field names + /// is intentionally avoided so this renderer does not couple to the + /// `SchemaDSL` grammar. + public let schemaDSL: String? + public let rows: [ListRow] + + public init(title: String, description: String?, schemaDSL: String?, rows: [ListRow]) { + self.title = title + self.description = description + self.schemaDSL = schemaDSL + self.rows = rows + } + } + + /// Renders a single list as a Markdown table ("structured table + /// conversion"): an H1 title, optional italic description, then a table + /// whose columns come from the schema (falling back to the union of row + /// keys). Cell values are pipe- and newline-escaped so the table never + /// breaks. A list with no derivable columns renders an explanatory line + /// instead of an empty table. + public func markdown(forList list: ListInput) -> String { + var out = "# \(list.title.isEmpty ? "Untitled list" : list.title)\n" + if let description = list.description?.trimmingCharacters(in: .whitespacesAndNewlines), + !description.isEmpty { + out += "\n_\(description)_\n" + } + + let columns = Self.columns(fromSchemaDSL: list.schemaDSL, rows: list.rows) + guard !columns.isEmpty else { + out += "\n_No columns defined._\n" + return out + } + + out += "\n" + out += "| " + columns.map(Self.escapeCell).joined(separator: " | ") + " |\n" + out += "| " + columns.map { _ in "---" }.joined(separator: " | ") + " |\n" + + if list.rows.isEmpty { + // A header with no data rows is still valid Markdown; keep the table + // shape and let the empty body speak for itself. + return out + } + + for row in list.rows { + let cells = columns.map { column -> String in + Self.escapeCell(row.fields[column]?.displayText ?? "") + } + out += "| " + cells.joined(separator: " | ") + " |\n" + } + return out + } + + /// Concatenates several lists into one Markdown document, separated by a + /// horizontal rule. Used by the "Export all my lists" flow. + public func markdown(forLists lists: [ListInput]) -> String { + lists.map { markdown(forList: $0) }.joined(separator: "\n---\n\n") + } + + // MARK: - Helpers + + /// Derives the ordered column set for a list table. Prefers the declared + /// schema (parsed just far enough to read the field *names*, left-of-colon); + /// falls back to the sorted union of keys observed across the rows so a + /// schemaless list still exports something sensible. + static func columns(fromSchemaDSL dsl: String?, rows: [ListRow]) -> [String] { + if let dsl { + let names = dsl + .split(separator: ",") + .compactMap { pair -> String? in + let name = pair.split(separator: ":", maxSplits: 1).first + .map { String($0).trimmingCharacters(in: .whitespaces) } ?? "" + return name.isEmpty ? nil : name + } + if !names.isEmpty { + return names + } + } + var seen = Set() + var ordered: [String] = [] + for row in rows { + for key in row.fields.keys.sorted() where !seen.contains(key) { + seen.insert(key) + ordered.append(key) + } + } + return ordered + } + + /// Escapes a value for a Markdown table cell: pipes are backslash-escaped + /// and newlines collapse to spaces so a multi-line value cannot split the + /// row across table lines. + static func escapeCell(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "|", with: "\\|") + .replacingOccurrences(of: "\r\n", with: " ") + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift index 615a6c2..004619f 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift @@ -86,7 +86,8 @@ public protocol MessagesServicing: Sendable { // 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`), + // `crossPostToBluesky`, `crossPostToLinkedIn`, `crossPostToTwitter`), + // 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 @@ -172,8 +173,8 @@ public protocol MessagesServicing: Sendable { /// - non-empty `imageURLs` / `videoURLs` requires `.mediaAttachments`; /// - a non-nil `scheduledAt` requires `.scheduledPosts`; /// - any cross-post target (`mastodonProviderIds` non-empty, - /// `crossPostToBluesky`, or `crossPostToLinkedIn`) requires - /// `.crossPosting`. + /// `crossPostToBluesky`, `crossPostToLinkedIn`, or `crossPostToTwitter`) + /// requires `.crossPosting`. /// /// All applicable gates are checked before the HTTP call; the first /// failing gate throws `MessagesError.subscriberRequired(feature)` and no @@ -188,7 +189,8 @@ public protocol MessagesServicing: Sendable { scheduledAt: Date?, mastodonProviderIds: [String], crossPostToBluesky: Bool, - crossPostToLinkedIn: Bool + crossPostToLinkedIn: Bool, + crossPostToTwitter: Bool ) async throws -> Message /// Loads the caller's pending scheduled posts (`GET /api/messages/scheduled`). @@ -549,7 +551,8 @@ public final class MessagesService: MessagesServicing { scheduledAt: Date?, mastodonProviderIds: [String], crossPostToBluesky: Bool, - crossPostToLinkedIn: Bool + crossPostToLinkedIn: Bool, + crossPostToTwitter: Bool ) async throws -> Message { // Gate every applicable subscriber feature *before* any HTTP call, so // the composer surfaces an upsell instead of a mid-flow 403 (PLAN.md @@ -561,7 +564,10 @@ public final class MessagesService: MessagesServicing { if scheduledAt != nil { try requireEntitlement(.scheduledPosts) } - let hasCrossPost = !mastodonProviderIds.isEmpty || crossPostToBluesky || crossPostToLinkedIn + let hasCrossPost = !mastodonProviderIds.isEmpty + || crossPostToBluesky + || crossPostToLinkedIn + || crossPostToTwitter if hasCrossPost { try requireEntitlement(.crossPosting) } @@ -578,7 +584,8 @@ public final class MessagesService: MessagesServicing { scheduledAt: scheduledAt, mastodonProviderIds: mastodonProviderIds.isEmpty ? nil : mastodonProviderIds, crossPostToBluesky: crossPostToBluesky ? true : nil, - crossPostToLinkedIn: crossPostToLinkedIn ? true : nil + crossPostToLinkedIn: crossPostToLinkedIn ? true : nil, + crossPostToTwitter: crossPostToTwitter ? true : nil ) let dto = try await api.send(Messages.create(request)) let message = Message(from: dto) diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ModerationService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ModerationService.swift new file mode 100644 index 0000000..be8bc8b --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ModerationService.swift @@ -0,0 +1,121 @@ +import Foundation +import InterlinedKit + +// MARK: - ModerationServicing + +/// The moderation surface the App layer codes against (the-gaps.md G2) — list +/// the accounts you block / mute, block / unblock, mute / unmute, and report +/// users or messages. +/// +/// Follows the domain-service DI shape: takes its `APIClientProtocol` so unit +/// tests run against a stub. The write actions are fire-and-forget (sent via +/// `sendVoid`); success is a non-throwing 2xx. `isBlocking(username:)` is +/// computed from the blocks list so it needs no unverified per-user status +/// endpoint. +public protocol ModerationServicing: Sendable { + func blockedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] + func mutedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] + + func block(username: String) async throws + func unblock(username: String) async throws + func mute(username: String) async throws + func unmute(username: String) async throws + + func reportUser(username: String, reason: ReportReason, detail: String?) async throws + func reportMessage(id: String, reason: ReportReason, detail: String?) async throws + + /// Whether the current account blocks `username`, computed by scanning the + /// blocks list (case-insensitive on username, or an id match). + func isBlocking(username: String) async throws -> Bool +} + +public extension ModerationServicing { + /// Convenience overloads with the default page size (50). + func blockedUsers() async throws -> [ModeratedUser] { + try await blockedUsers(limit: 50, offset: 0) + } + func mutedUsers() async throws -> [ModeratedUser] { + try await mutedUsers(limit: 50, offset: 0) + } + func reportUser(username: String, reason: ReportReason) async throws { + try await reportUser(username: username, reason: reason, detail: nil) + } + func reportMessage(id: String, reason: ReportReason) async throws { + try await reportMessage(id: id, reason: reason, detail: nil) + } +} + +// MARK: - ModerationService + +public final class ModerationService: ModerationServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + // MARK: Lists + + public func blockedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] { + let dto = try await api.send(Moderation.blocks(limit: limit, offset: offset)) + return dto.blockedUsers.map(ModeratedUser.init(from:)) + } + + public func mutedUsers(limit: Int, offset: Int) async throws -> [ModeratedUser] { + let dto = try await api.send(Moderation.mutes(limit: limit, offset: offset)) + return dto.mutedUsers.map(ModeratedUser.init(from:)) + } + + // MARK: Block / mute actions (fire-and-forget) + + public func block(username: String) async throws { + try await api.sendVoid(Moderation.block(username: username)) + } + + public func unblock(username: String) async throws { + try await api.sendVoid(Moderation.unblock(username: username)) + } + + public func mute(username: String) async throws { + try await api.sendVoid(Moderation.mute(username: username)) + } + + public func unmute(username: String) async throws { + try await api.sendVoid(Moderation.unmute(username: username)) + } + + // MARK: Reporting + + public func reportUser(username: String, reason: ReportReason, detail: String?) async throws { + try await api.sendVoid( + Moderation.reportUser(username: username, reason: reason.rawValue, detail: Self.normalize(detail)) + ) + } + + public func reportMessage(id: String, reason: ReportReason, detail: String?) async throws { + try await api.sendVoid( + Moderation.reportMessage(id: id, reason: reason.rawValue, detail: Self.normalize(detail)) + ) + } + + // MARK: Derived state + + public func isBlocking(username: String) async throws -> Bool { + let blocked = try await blockedUsers(limit: 100, offset: 0) + return blocked.contains { user in + user.id == username + || user.username?.caseInsensitiveCompare(username) == .orderedSame + } + } + + // MARK: - Helpers + + /// Trims free-text detail and collapses an empty string to `nil` so the + /// request omits the field rather than sending `""`. + private static func normalize(_ detail: String?) -> String? { + guard let trimmed = detail?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SearchService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SearchService.swift new file mode 100644 index 0000000..2bfcd90 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SearchService.swift @@ -0,0 +1,107 @@ +import Foundation +import InterlinedKit + +// MARK: - SearchResults + +/// The combined result of a global search (the-gaps.md G5). Each resource is +/// searched independently; the App surface groups them under one search field. +public struct SearchResults: Sendable, Equatable { + public var messages: [Message] + public var lists: [ListSummary] + public var documents: [Document] + + public init(messages: [Message] = [], lists: [ListSummary] = [], documents: [Document] = []) { + self.messages = messages + self.lists = lists + self.documents = documents + } + + /// `true` when no resource returned a hit. + public var isEmpty: Bool { messages.isEmpty && lists.isEmpty && documents.isEmpty } + + /// Total hit count across all three resources. + public var totalCount: Int { messages.count + lists.count + documents.count } + + public static let empty = SearchResults() +} + +// MARK: - SearchServicing + +/// The search surface the App layer codes against — full-text search over the +/// current user's messages, lists, and documents (the-gaps.md G5). +/// +/// Follows the domain-service DI shape: takes its `APIClientProtocol` as a +/// parameter so unit tests run against a stub. Per decision 0003 the App layer +/// never sees the kit DTOs — this service returns domain values (`Message`, +/// `ListSummary`, `Document`) mapped through the existing per-resource mappers. +/// +/// A blank/whitespace-only query short-circuits to an empty result **without** +/// hitting the network — the global search field calls these on every keystroke, +/// so an empty term must not fan out three pointless requests. +public protocol SearchServicing: Sendable { + func messages(query: String, limit: Int, offset: Int) async throws -> [Message] + func lists(query: String, limit: Int, offset: Int) async throws -> [ListSummary] + func documents(query: String, limit: Int, offset: Int) async throws -> [Document] +} + +public extension SearchServicing { + /// Convenience overloads with the default page size (20). + func messages(query: String) async throws -> [Message] { + try await messages(query: query, limit: 20, offset: 0) + } + func lists(query: String) async throws -> [ListSummary] { + try await lists(query: query, limit: 20, offset: 0) + } + func documents(query: String) async throws -> [Document] { + try await documents(query: query, limit: 20, offset: 0) + } + + /// Searches all three resources concurrently and merges the hits. A blank + /// query returns `.empty` without any request. If any leg fails the whole + /// call throws — the App layer can fall back to per-tab searches when it + /// needs partial results. + func all(query: String, limit: Int = 20) async throws -> SearchResults { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .empty } + async let messages = self.messages(query: trimmed, limit: limit, offset: 0) + async let lists = self.lists(query: trimmed, limit: limit, offset: 0) + async let documents = self.documents(query: trimmed, limit: limit, offset: 0) + return SearchResults( + messages: try await messages, + lists: try await lists, + documents: try await documents + ) + } +} + +// MARK: - SearchService + +public final class SearchService: SearchServicing { + + private let api: APIClientProtocol + + public init(api: APIClientProtocol) { + self.api = api + } + + public func messages(query: String, limit: Int, offset: Int) async throws -> [Message] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + let dto = try await api.send(Search.messages(query: trimmed, limit: limit, offset: offset)) + return dto.messages.map(Message.init(from:)) + } + + public func lists(query: String, limit: Int, offset: Int) async throws -> [ListSummary] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + let dto = try await api.send(Search.lists(query: trimmed, limit: limit, offset: offset)) + return dto.lists.map(ListSummary.init(from:)) + } + + public func documents(query: String, limit: Int, offset: Int) async throws -> [Document] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + let dto = try await api.send(Search.documents(query: trimmed, limit: limit, offset: offset)) + return dto.documents.map(Document.init(from:)) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift new file mode 100644 index 0000000..534cdc5 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift @@ -0,0 +1,127 @@ +import Foundation +import InterlinedKit + +// MARK: - SharingError + +public enum SharingError: Error, Sendable, Equatable { + /// Creating a share link requires an active subscription (free tier gets a + /// 403 server-side). Raised before any HTTP call. + case subscriberRequired +} + +extension SharingError: LocalizedError, CustomStringConvertible { + public var errorDescription: String? { description } + public var description: String { + switch self { + case .subscriberRequired: return "Creating share links requires an active subscription." + } + } +} + +// MARK: - SharingServicing + +/// The sharing surface the App layer codes against (the-gaps.md G3) — create, +/// list, and revoke tokenized share links for lists and documents, and +/// resolve/claim a link from a pasted URL or `interlinedlist://` deep link. +/// +/// Creating a link is subscriber-gated (mirrors `ListsService`'s entitlement +/// seam); resolving/claiming/revoking are not, so downgraded owners can still +/// revoke and any recipient can claim. +public protocol SharingServicing: Sendable { + // Lists + func listShareLinks(listId: String) async throws -> [ShareLink] + func createListShareLink(listId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink + func revokeListShareLink(listId: String, token: String) async throws -> Bool + func resolveListShare(token: String) async throws -> ResolvedShare + func claimListShare(token: String) async throws -> ShareClaim + + // Documents + func documentShareLinks(documentId: String) async throws -> [ShareLink] + func createDocumentShareLink(documentId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink + func revokeDocumentShareLink(documentId: String, token: String) async throws -> Bool + func resolveDocumentShare(token: String) async throws -> ResolvedShare + func claimDocumentShare(token: String) async throws -> ShareClaim +} + +public extension SharingServicing { + func createListShareLink(listId: String, role: ShareRole = .watcher) async throws -> ShareLink { + try await createListShareLink(listId: listId, role: role, expiresAt: nil) + } + func createDocumentShareLink(documentId: String, role: ShareRole = .watcher) async throws -> ShareLink { + try await createDocumentShareLink(documentId: documentId, role: role, expiresAt: nil) + } +} + +// MARK: - SharingService + +public final class SharingService: SharingServicing { + + private let api: APIClientProtocol + private let entitlements: EntitlementsService + + public init( + api: APIClientProtocol, + entitlements: EntitlementsService = EntitlementsService(customerStatus: .free) + ) { + self.api = api + self.entitlements = entitlements + } + + // MARK: Lists + + public func listShareLinks(listId: String) async throws -> [ShareLink] { + let dto = try await api.send(Sharing.listShareLinks(listId: listId)) + return dto.shareLinks.map(ShareLink.init(from:)) + } + + public func createListShareLink(listId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink { + guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + let dto = try await api.send( + Sharing.createListShareLink(listId: listId, CreateShareLinkRequest(role: role.rawValue, expiresAt: expiresAt)) + ) + return ShareLink(from: dto) + } + + public func revokeListShareLink(listId: String, token: String) async throws -> Bool { + let dto = try await api.send(Sharing.revokeListShareLink(listId: listId, token: token)) + return dto.revoked ?? true + } + + public func resolveListShare(token: String) async throws -> ResolvedShare { + ResolvedShare(from: try await api.send(Sharing.resolveListShare(token: token))) + } + + public func claimListShare(token: String) async throws -> ShareClaim { + let dto = try await api.send(Sharing.claimListShare(token: token)) + return ShareClaim(resourceId: dto.listId, role: dto.role.flatMap(ShareRole.init(rawValue:))) + } + + // MARK: Documents + + public func documentShareLinks(documentId: String) async throws -> [ShareLink] { + let dto = try await api.send(Sharing.documentShareLinks(documentId: documentId)) + return dto.shareLinks.map(ShareLink.init(from:)) + } + + public func createDocumentShareLink(documentId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink { + guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + let dto = try await api.send( + Sharing.createDocumentShareLink(documentId: documentId, CreateShareLinkRequest(role: role.rawValue, expiresAt: expiresAt)) + ) + return ShareLink(from: dto) + } + + public func revokeDocumentShareLink(documentId: String, token: String) async throws -> Bool { + let dto = try await api.send(Sharing.revokeDocumentShareLink(documentId: documentId, token: token)) + return dto.revoked ?? true + } + + public func resolveDocumentShare(token: String) async throws -> ResolvedShare { + ResolvedShare(from: try await api.send(Sharing.resolveDocumentShare(token: token))) + } + + public func claimDocumentShare(token: String) async throws -> ShareClaim { + let dto = try await api.send(Sharing.claimDocumentShare(token: token)) + return ShareClaim(resourceId: dto.documentId, role: dto.role.flatMap(ShareRole.init(rawValue:))) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift index 3f0a5ac..f22f533 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SocialService.swift @@ -153,11 +153,26 @@ public final class SocialService: SocialServicing { // 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. + // D2: prefer the dedicated public-profile endpoint (`GET + // /api/users/{username}`), which is rich (bio, join date, private + // flag, follower/following counts) — verified live 2026-07-31. + do { + let dto = try await api.send(User.publicProfile(username: username)) + return UserProfile(from: dto) + } catch let error as APIError { + // Decision-0002 fallback, retained only for pre-migration servers + // that 404 the endpoint: derive identity from the embedded author + // on the user's public messages. Any other error propagates. + guard case .notFound = error else { throw error } + return try await profileFromEmbeddedAuthor(username: username) + } + } + + /// Reduced-scope profile projection from the embedded author on a user's + /// public messages (decision 0002). Used only when the public-profile + /// endpoint is unavailable (404). Bio / counts / joinedAt are `nil` and + /// `isPrivate` is `false` — the embedded author carries identity only. + private func profileFromEmbeddedAuthor(username: String) async throws -> UserProfile { let request = Messages.userMessages(username: username, limit: 1, offset: 0) let (data, _) = try await api.sendRaw(request) let key = request.paginationKey ?? "messages" @@ -168,8 +183,6 @@ public final class SocialService: SocialServicing { 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) diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DirectMessagesServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DirectMessagesServiceTests.swift new file mode 100644 index 0000000..b781846 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DirectMessagesServiceTests.swift @@ -0,0 +1,182 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `DirectMessagesService` (the-gaps.md G1). Quartet per +/// public method: happy + invalid + failure + empty/boundary. +final class DirectMessagesServiceTests: XCTestCase { + + private func dmJSON(id: String, senderId: String = "s", recipientId: String = "r") -> String { + #""" + {"id":"\#(id)","pairKey":"s:r","senderId":"\#(senderId)","recipientId":"\#(recipientId)", + "body":"hi there","imageUrls":["https://cdn/a.png"], + "createdAt":"2026-07-31T22:20:32.337Z","readAt":null, + "sender":{"id":"s","username":"messenger","displayName":"Messenger"}, + "recipient":{"id":"r","username":"adron","displayName":"Adron"}, + "preview":"hi there"} + """# + } + + // MARK: - folder + + func test_givenInbox_whenLoadingFolder_thenMapsMessagesAndHitsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"items":[\#(dmJSON(id: "m1"))],"nextCursor":"c2"}"#) + let service = DirectMessagesService(api: api) + + let page = try await service.folder(.inbox) + + XCTAssertEqual(page.messages.map(\.id), ["m1"]) + XCTAssertEqual(page.messages.first?.imageURLs.first?.absoluteString, "https://cdn/a.png") + XCTAssertEqual(page.nextCursor, "c2") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm") + XCTAssertEqual(recorded.first?.query["folder"], "inbox") + } + + func test_givenServerFailure_whenLoadingFolder_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = DirectMessagesService(api: api) + + do { + _ = try await service.folder(.sent) + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + func test_givenEmpty_whenLoadingFolder_thenReturnsEmptyPage() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"items":[],"nextCursor":null}"#) + let service = DirectMessagesService(api: api) + + let page = try await service.folder(.deleted) + + XCTAssertTrue(page.messages.isEmpty) + } + + // MARK: - thread + + func test_givenThread_whenLoading_thenMapsMessagesAndMetadata() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"items":[\#(dmJSON(id: "m1"))],"olderCursor":null,"isMutual":true,"isBlocked":false, + "otherUser":{"id":"r","username":"adron","displayName":"Adron"}} + """#) + let service = DirectMessagesService(api: api) + + let thread = try await service.thread(username: "adron") + + XCTAssertEqual(thread.messages.map(\.id), ["m1"]) + XCTAssertTrue(thread.isMutual) + XCTAssertFalse(thread.isBlocked) + XCTAssertEqual(thread.otherUser?.username, "adron") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm/thread/adron") + } + + // MARK: - send + + func test_givenBody_whenSending_thenMapsWrappedMessageAndPostsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"message":\#(dmJSON(id: "m9"))}"#) + let service = DirectMessagesService(api: api) + + let message = try await service.send(recipientId: "r", body: " hi there ") + + XCTAssertEqual(message.id, "m9") + XCTAssertTrue(message.isOutgoing(currentUserId: "s")) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/dm") + } + + func test_givenEmptyBodyAndNoImages_whenSending_thenThrowsWithoutRequest() async throws { + let api = StubAPIClient() + let service = DirectMessagesService(api: api) + + do { + _ = try await service.send(recipientId: "r", body: " ") + XCTFail("Expected emptyMessage") + } catch let error as DirectMessagesError { + XCTAssertEqual(error, .emptyMessage) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "An empty message must not hit the network") + } + + func test_givenIneligibleRecipient_whenSending_thenPropagatesForbidden() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .forbidden(serverMessage: "not mutual followers")) + let service = DirectMessagesService(api: api) + + do { + _ = try await service.send(recipientId: "r", body: "hi") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "not mutual followers")) + } + } + + // MARK: - recipients / unreadCount + + func test_givenRecipients_whenLoading_thenMapsUsers() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"recipients":[{"id":"r","username":"adron","displayName":"Adron"}]}"#) + let service = DirectMessagesService(api: api) + + let users = try await service.recipients() + + XCTAssertEqual(users.map(\.id), ["r"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm/recipients") + } + + func test_givenUnreadCount_whenLoading_thenReturnsCount() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"count":4}"#) + let service = DirectMessagesService(api: api) + + let count = try await service.unreadCount() + + XCTAssertEqual(count, 4) + } + + // MARK: - read / trash / restore + + func test_givenId_whenMarkingRead_thenPostsReadPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"ok":true}"#) + let service = DirectMessagesService(api: api) + + try await service.markRead(id: "m1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/dm/m1/read") + } + + func test_givenId_whenTrashing_thenPostsTrashPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"ok":true}"#) + let service = DirectMessagesService(api: api) + + try await service.trash(id: "m1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm/m1/trash") + } + + func test_givenId_whenRestoring_thenPostsRestorePath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"ok":true}"#) + let service = DirectMessagesService(api: api) + + try await service.restore(id: "m1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/dm/m1/restore") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplateTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplateTests.swift new file mode 100644 index 0000000..b89079c --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplateTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import InterlinedDomain + +/// BDD-named tests for the client-side document-template catalog +/// (feature-gaps.md §1.4). Pure value-type checks — the catalog is static and +/// dependency-free, so there is no service to stub. +final class DocumentTemplateTests: XCTestCase { + + // MARK: - Catalog shape + + func test_givenBuiltInCatalog_whenInspected_thenIsNonEmpty() { + // Happy path: the picker always has something to show. + XCTAssertFalse(DocumentTemplate.builtIn.isEmpty) + } + + func test_givenBuiltInCatalog_whenInspected_thenIdsAreUnique() { + let ids = DocumentTemplate.builtIn.map(\.id) + XCTAssertEqual(ids.count, Set(ids).count) + } + + func test_givenBuiltInCatalog_whenInspected_thenNamesAndSummariesAreNonBlank() { + // Boundary: no template ships with a blank name or summary — those are + // the only two fields the picker renders as user-facing text. + for template in DocumentTemplate.builtIn { + XCTAssertFalse( + template.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "template \(template.id) has a blank name" + ) + XCTAssertFalse( + template.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "template \(template.id) has a blank summary" + ) + } + } + + // MARK: - Blank identity + + func test_givenBlankTemplate_whenInspected_thenSeedsEmptyBody() { + // The blank path must be byte-for-byte the existing "new blank + // document" behavior — an empty markdown body. + XCTAssertEqual(DocumentTemplate.blank.bodyMarkdown, "") + XCTAssertEqual(DocumentTemplate.blank.body, .empty) + } + + func test_givenBuiltInCatalog_whenInspected_thenFirstEntryIsBlank() { + // The picker leans on this ordering to keep "start from scratch" first. + XCTAssertEqual(DocumentTemplate.builtIn.first?.id, DocumentTemplate.blank.id) + } + + // MARK: - Named templates seed real content + + func test_givenNamedTemplate_whenInspected_thenSeedsNonEmptyMarkdown() { + // Every non-blank template seeds real starter Markdown, and its typed + // `body` matches its raw `bodyMarkdown`. + let named = DocumentTemplate.builtIn.filter { $0.id != DocumentTemplate.blank.id } + XCTAssertFalse(named.isEmpty, "expected at least one non-blank template") + for template in named { + XCTAssertFalse( + template.bodyMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "template \(template.id) seeds no content" + ) + XCTAssertEqual(template.body, DocumentBody(markdown: template.bodyMarkdown)) + } + } + + func test_givenMeetingNotesTemplate_whenInspected_thenContainsExpectedSections() { + // Named-template happy path: the Meeting Notes body carries its + // signature Action Items section. + let template = DocumentTemplate.meetingNotes + XCTAssertTrue(template.bodyMarkdown.contains("# Meeting Notes")) + XCTAssertTrue(template.bodyMarkdown.contains("## Action Items")) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplatesServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplatesServiceTests.swift new file mode 100644 index 0000000..192be81 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplatesServiceTests.swift @@ -0,0 +1,83 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `DocumentTemplatesService` (the-gaps.md G12). +final class DocumentTemplatesServiceTests: XCTestCase { + + func test_givenTemplates_whenLoading_thenMapsRefsAndHitsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"folderCreated":false,"templatesFolderId":"f-tpl", + "templates":[{"id":"t1","title":"Recipe","relativePath":"recipe.md"}]} + """#) + let service = DocumentTemplatesService(api: api) + + let templates = try await service.templates() + + XCTAssertEqual(templates.map(\.id), ["t1"]) + XCTAssertEqual(templates.first?.title, "Recipe") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents/templates") + XCTAssertEqual(recorded.first?.method, "GET") + } + + func test_givenEmpty_whenLoadingTemplates_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"templates":[]}"#) + let service = DocumentTemplatesService(api: api) + + let templates = try await service.templates() + + XCTAssertTrue(templates.isEmpty) + } + + func test_givenServerFailure_whenLoadingTemplates_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = DocumentTemplatesService(api: api) + + do { + _ = try await service.templates() + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + func test_givenTemplateId_whenCreatingFromTemplate_thenPostsFromTemplatePath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{}"#) + let service = DocumentTemplatesService(api: api) + + try await service.createFromTemplate(templateDocumentId: "t1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/documents/from-template") + } + + func test_givenCreateFails_whenCreatingFromTemplate_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: "templateDocumentId is required.")) + let service = DocumentTemplatesService(api: api) + + do { + try await service.createFromTemplate(templateDocumentId: "") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "templateDocumentId is required.")) + } + } + + func test_whenSeedingDefaults_thenPostsSeedPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{}"#) + let service = DocumentTemplatesService(api: api) + + try await service.seedDefaultTemplates() + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents/templates/seed-defaults") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/LinkedInServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/LinkedInServiceTests.swift new file mode 100644 index 0000000..942253d --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/LinkedInServiceTests.swift @@ -0,0 +1,55 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `LinkedInService` (the-gaps.md G11a). +final class LinkedInServiceTests: XCTestCase { + + func test_givenTargets_whenLoading_thenMapsAndFlagsOrgScope() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"targets":[ + {"kind":"personal","label":"Adron Hall","avatarUrl":"https://cdn/a.png","enabled":true}, + {"kind":"org","label":"Bikey Life","avatarUrl":null,"enabled":false} + ],"orgScopeMissing":true} + """#) + let service = LinkedInService(api: api) + + let result = try await service.postingTargets() + + XCTAssertEqual(result.targets.map(\.kind), [.personal, .org]) + XCTAssertTrue(result.targets.first?.isEnabled ?? false) + XCTAssertTrue(result.orgScopeMissing) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/linkedin/posting-targets") + } + + func test_givenEmpty_whenLoading_thenReturnsEmptyTargets() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"targets":[]}"#) + let service = LinkedInService(api: api) + + let result = try await service.postingTargets() + + XCTAssertTrue(result.targets.isEmpty) + XCTAssertFalse(result.orgScopeMissing) + } + + func test_givenServerFailure_whenLoading_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = LinkedInService(api: api) + + do { + _ = try await service.postingTargets() + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + func test_givenUnknownKind_whenMapping_thenFallsBackToOther() { + let target = LinkedInTarget(from: LinkedInTargetDTO(kind: "showcase", label: "X")) + XCTAssertEqual(target.kind, .other) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListFoldersServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListFoldersServiceTests.swift new file mode 100644 index 0000000..b137b07 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ListFoldersServiceTests.swift @@ -0,0 +1,167 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `ListFoldersService` (the-gaps.md G6). Quartet per +/// public method: happy + invalid + failure + empty/boundary. Includes the +/// subscriber gate and the tree builder. +final class ListFoldersServiceTests: XCTestCase { + + private func subscriberService(_ api: StubAPIClient) -> ListFoldersService { + ListFoldersService(api: api, entitlements: EntitlementsService(customerStatus: .subscriber)) + } + + // MARK: - folders + + func test_givenFolders_whenLoading_thenMapsRowsAndHitsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"folders":[{"id":"f1","name":"Root","parentId":null}]}"#) + let service = subscriberService(api) + + let folders = try await service.folders() + + XCTAssertEqual(folders.map(\.id), ["f1"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/folders") + XCTAssertEqual(recorded.first?.method, "GET") + } + + func test_givenEmpty_whenLoadingFolders_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"folders":[]}"#) + let service = subscriberService(api) + + let folders = try await service.folders() + + XCTAssertTrue(folders.isEmpty) + } + + func test_givenServerFailure_whenLoadingFolders_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = subscriberService(api) + + do { + _ = try await service.folders() + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - create (subscriber-gated) + + func test_givenSubscriber_whenCreating_thenPostsAndMapsFolder() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"id":"f9","name":"Bikes","parentId":null}"#) + let service = subscriberService(api) + + let folder = try await service.create(name: " Bikes ") + + XCTAssertEqual(folder.id, "f9") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/folders") + } + + func test_givenFreeUser_whenCreating_thenThrowsSubscriberRequiredWithoutRequest() async throws { + let api = StubAPIClient() + let service = ListFoldersService(api: api, entitlements: EntitlementsService(customerStatus: .free)) + + do { + _ = try await service.create(name: "Bikes") + XCTFail("Expected subscriberRequired") + } catch let error as ListFoldersError { + XCTAssertEqual(error, .subscriberRequired) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "The subscriber gate must short-circuit before any HTTP call") + } + + func test_givenBlankName_whenCreating_thenThrowsInvalidName() async throws { + let api = StubAPIClient() + let service = subscriberService(api) + + do { + _ = try await service.create(name: " ") + XCTFail("Expected invalidName") + } catch let error as ListFoldersError { + XCTAssertEqual(error, .invalidName) + } + } + + // MARK: - rename / move / delete + + func test_givenName_whenRenaming_thenPutsToFolderPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"id":"f1","name":"Renamed","parentId":null}"#) + let service = subscriberService(api) + + let folder = try await service.rename(id: "f1", name: "Renamed") + + XCTAssertEqual(folder.name, "Renamed") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "PUT") + XCTAssertEqual(recorded.first?.path, "/api/folders/f1") + } + + func test_givenParent_whenMoving_thenPutsToFolderPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"id":"f2","name":"Child","parentId":"f1"}"#) + let service = subscriberService(api) + + let folder = try await service.move(id: "f2", toParent: "f1") + + XCTAssertEqual(folder.parentId, "f1") + } + + func test_givenId_whenDeleting_thenDeletesFolderPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{}"#) + let service = subscriberService(api) + + try await service.delete(id: "f1") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/folders/f1") + } + + // MARK: - tree builder + + func test_givenFlatFolders_whenBuildingTree_thenNestsByParentId() { + let folders = [ + ListFolder(id: "r1", name: "Root 1", parentId: nil), + ListFolder(id: "c1", name: "Child 1", parentId: "r1"), + ListFolder(id: "c2", name: "Child 2", parentId: "r1"), + ListFolder(id: "g1", name: "Grandchild", parentId: "c1"), + ListFolder(id: "r2", name: "Root 2", parentId: nil) + ] + + let tree = ListFolder.tree(from: folders) + + XCTAssertEqual(tree.map(\.id), ["r1", "r2"]) + XCTAssertEqual(tree.first?.children.map(\.id), ["c1", "c2"]) + XCTAssertEqual(tree.first?.children.first?.children.map(\.id), ["g1"]) + } + + func test_givenDanglingParent_whenBuildingTree_thenOrphanBecomesRoot() { + let folders = [ + ListFolder(id: "a", name: "A", parentId: "missing"), + ListFolder(id: "b", name: "B", parentId: nil) + ] + + let tree = ListFolder.tree(from: folders) + + XCTAssertEqual(Set(tree.map(\.id)), ["a", "b"], "A folder with a dangling parent must not be lost") + } + + func test_givenSelfParentCycle_whenBuildingTree_thenDoesNotRecurseInfinitely() { + let folders = [ListFolder(id: "a", name: "A", parentId: "a")] + + let tree = ListFolder.tree(from: folders) + + XCTAssertEqual(tree.map(\.id), ["a"]) + XCTAssertTrue(tree.first?.children.isEmpty ?? false) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift index cd5de23..d5a9260 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift @@ -183,13 +183,15 @@ final class MapperTests: XCTestCase { tags: [String]? = ["swift"], digCount: Int = 3, dugByMe: Bool = false, - pushedMessage: PushedMessageBox? = nil + pushedMessage: PushedMessageBox? = nil, + linkMetadata: LinkMetadataDTO? = nil ) -> MessageDTO { MessageDTO( id: id, content: "hello", publiclyVisible: publiclyVisible, userId: "u1", + linkMetadata: linkMetadata, tags: tags, createdAt: date, updatedAt: date, @@ -285,4 +287,147 @@ final class MapperTests: XCTestCase { XCTFail("Expected .unknown, got \(result.status)") } } + + // MARK: - LinkPreview mapper (feature-gaps §1.5) + + // Happy path: a fully-resolved preview with every field populated maps to a + // single renderable `LinkPreview` on the message. + func test_givenMessageWithFullLinkMetadata_whenMapped_thenCarriesRenderablePreview() throws { + // Given + let link = LinkPreviewDTO( + url: "https://example.com/article", + platform: "web", + fetchStatus: "ready", + title: "A Great Article", + description: "All about widgets.", + imageUrl: "https://cdn.example.com/thumb.png" + ) + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [link])) + + // When + let message = Message(from: dto) + + // Then + XCTAssertEqual(message.linkPreviews.count, 1) + let preview = try XCTUnwrap(message.linkPreviews.first) + XCTAssertEqual(preview.url, URL(string: "https://example.com/article")) + XCTAssertEqual(preview.platform, "web") + XCTAssertEqual(preview.fetchStatus, "ready") + XCTAssertEqual(preview.title, "A Great Article") + XCTAssertEqual(preview.description, "All about widgets.") + XCTAssertEqual(preview.imageURL, URL(string: "https://cdn.example.com/thumb.png")) + XCTAssertEqual(preview.displayHost, "example.com") + XCTAssertTrue(preview.isRenderable) + } + + // Boundary: a bare URL with no title / image / ready status still maps + // (the URL parses) but is reported as not worth rendering, so the UI shows + // no card for it. + func test_givenMessageWithBareURLOnlyLink_whenMapped_thenPreviewMapsButIsNotRenderable() throws { + // Given — nothing but a URL; fetch not (yet) resolved. + let link = LinkPreviewDTO(url: "https://example.com/pending", fetchStatus: "pending") + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [link])) + + // When + let message = Message(from: dto) + + // Then + XCTAssertEqual(message.linkPreviews.count, 1) + let preview = try XCTUnwrap(message.linkPreviews.first) + XCTAssertNil(preview.title) + XCTAssertNil(preview.imageURL) + XCTAssertFalse(preview.isFetchStatusReady) + XCTAssertFalse(preview.isRenderable) + } + + // Invalid input: an entry whose `url` will not parse is dropped by the + // mapper's `compactMap`, while a sibling valid entry survives. + func test_givenMessageWithUnparseableLinkURL_whenMapped_thenThatEntryIsDropped() throws { + // Given — an empty-string URL cannot form a `URL`; a valid sibling can. + let bad = LinkPreviewDTO(url: "", title: "Broken") + let good = LinkPreviewDTO(url: "https://example.com/ok", title: "Good", imageUrl: nil) + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [bad, good])) + + // When + let message = Message(from: dto) + + // Then — only the parseable entry survives. + XCTAssertEqual(message.linkPreviews.count, 1) + XCTAssertEqual(message.linkPreviews.first?.url, URL(string: "https://example.com/ok")) + } + + // Empty / boundary: no `linkMetadata` at all collapses to an empty array + // (never `nil`), matching the `crossPostResults` default. + func test_givenMessageWithNoLinkMetadata_whenMapped_thenLinkPreviewsAreEmpty() { + // Given / When + let message = Message(from: makeMessageDTO(linkMetadata: nil)) + + // Then + XCTAssertEqual(message.linkPreviews, []) + } + + // Unparseable image only: the entry is kept (its `url` parses) with a nil + // image, so the card degrades to a thumbnail-less preview rather than being + // dropped. An empty-string `imageUrl` will not form a `URL`. + func test_givenLinkWithUnparseableImageURL_whenMapped_thenPreviewKeptWithoutImage() throws { + // Given + let link = LinkPreviewDTO( + url: "https://example.com/story", + fetchStatus: "ready", + title: "Story", + imageUrl: "" // empty — not a valid URL + ) + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [link])) + + // When + let preview = try XCTUnwrap(Message(from: dto).linkPreviews.first) + + // Then + XCTAssertNil(preview.imageURL) + XCTAssertTrue(preview.isRenderable) // title alone is enough + } + + // MARK: - LinkPreview display rules (feature-gaps §1.5) + + // A recognised success status renders even when title/image are absent — + // the client is forward-compatible about which token means "ready". + func test_givenReadyFetchStatusWithoutTitleOrImage_whenEvaluated_thenIsRenderable() { + // Given + let preview = LinkPreview(url: URL(string: "https://example.com")!, fetchStatus: "SUCCESS") + + // Then — case-insensitive match on a known success token. + XCTAssertTrue(preview.isFetchStatusReady) + XCTAssertTrue(preview.isRenderable) + } + + // An image with no title still renders (image is a human-meaningful field). + func test_givenImageOnlyPreview_whenEvaluated_thenIsRenderable() { + // Given + let preview = LinkPreview( + url: URL(string: "https://example.com")!, + imageURL: URL(string: "https://cdn.example.com/i.png")! + ) + + // Then + XCTAssertTrue(preview.isRenderable) + } + + // Whitespace-only title is treated as absent for rendering purposes. + func test_givenWhitespaceOnlyTitleAndNoImage_whenEvaluated_thenIsNotRenderable() { + // Given + let preview = LinkPreview(url: URL(string: "https://example.com")!, title: " ") + + // Then + XCTAssertFalse(preview.isRenderable) + } + + // `www.` is stripped from the display host; a URL without a host falls back + // to the full string. + func test_givenHostWithWWWPrefix_whenReadingDisplayHost_thenPrefixStripped() { + // Given + let preview = LinkPreview(url: URL(string: "https://www.example.com/x")!) + + // Then + XCTAssertEqual(preview.displayHost, "example.com") + } } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MarkdownExporterTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MarkdownExporterTests.swift new file mode 100644 index 0000000..84f7f45 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MarkdownExporterTests.swift @@ -0,0 +1,221 @@ +import XCTest +@testable import InterlinedDomain + +/// BDD-named coverage for `MarkdownExporter` — the client-side Markdown +/// renderer that implements the "Markdown export for lists, documents, and +/// threads" parity feature (feature-gaps.md §1.3). The renderer is a pure +/// value transformer, so every surface is asserted structurally without any +/// network or clock dependency. +final class MarkdownExporterTests: XCTestCase { + + private let exporter = MarkdownExporter() + + // MARK: - Fixtures + + private func user(_ handle: String) -> UserSummary { + UserSummary(id: "u-\(handle)", username: handle, displayName: handle.capitalized) + } + + private func message( + id: String, + handle: String, + text: String, + at seconds: TimeInterval + ) -> Message { + Message( + id: id, + author: user(handle), + text: text, + createdAt: Date(timeIntervalSince1970: seconds), + updatedAt: Date(timeIntervalSince1970: seconds), + visibility: .public, + digCount: 0, + didDig: false, + repostCount: 0 + ) + } + + private func row(_ id: String, _ fields: [String: ListCellValue]) -> ListRow { + ListRow(id: id, listID: "L1", fields: fields) + } + + // MARK: - Documents + + func test_givenDocumentWithTitleAndBody_whenRendered_thenEmitsHeadingThenBody() { + // Given a long-form document whose body is already Markdown. + let doc = Document( + id: "d1", + title: "Release Notes", + body: DocumentBody(markdown: "## v1\n\n- Fixed things"), + updatedAt: Date(timeIntervalSince1970: 0) + ) + + // When + let md = exporter.markdown(for: doc) + + // Then — H1 title first, body preserved verbatim (not escaped). + XCTAssertTrue(md.hasPrefix("# Release Notes\n"), "title heading missing: \(md)") + XCTAssertTrue(md.contains("## v1"), "body markdown should pass through verbatim") + XCTAssertTrue(md.contains("- Fixed things")) + } + + func test_givenDocumentWithEmptyTitle_whenRendered_thenUsesUntitled() { + let doc = Document(id: "d1", title: "", updatedAt: Date(timeIntervalSince1970: 0)) + let md = exporter.markdown(for: doc) + XCTAssertTrue(md.hasPrefix("# Untitled\n"), "empty title should fall back to Untitled: \(md)") + } + + func test_givenDocumentWithEmptyBody_whenRendered_thenOnlyHeadingRemains() { + let doc = Document(id: "d1", title: "Empty", body: .empty, updatedAt: Date(timeIntervalSince1970: 0)) + let md = exporter.markdown(for: doc) + XCTAssertEqual(md, "# Empty\n", "an empty body should leave just the heading") + } + + // MARK: - Threads + + func test_givenThreadRootOnly_whenRendered_thenNoRepliesSection() { + // Given a root post with no replies. + let root = message(id: "m1", handle: "alice", text: "Original post", at: 100) + + // When + let md = exporter.markdown(forThreadRoot: root, replies: []) + + // Then + XCTAssertTrue(md.contains("# Thread")) + XCTAssertTrue(md.contains("**@alice**"), "author handle should be bolded") + XCTAssertTrue(md.contains("Original post")) + XCTAssertFalse(md.contains("## Replies"), "no replies section when there are no replies") + } + + func test_givenRepliesOutOfOrder_whenRendered_thenSortedByCreatedAtAscending() { + // Given the root and two replies passed newest-first. + let root = message(id: "m1", handle: "alice", text: "Root", at: 100) + let late = message(id: "m3", handle: "carol", text: "LATE_REPLY", at: 300) + let early = message(id: "m2", handle: "bob", text: "EARLY_REPLY", at: 200) + + // When — deliberately pass out of chronological order. + let md = exporter.markdown(forThreadRoot: root, replies: [late, early]) + + // Then — replies section exists and the earlier reply renders first. + XCTAssertTrue(md.contains("## Replies")) + let earlyIdx = try? XCTUnwrap(md.range(of: "EARLY_REPLY")).lowerBound + let lateIdx = try? XCTUnwrap(md.range(of: "LATE_REPLY")).lowerBound + XCTAssertNotNil(earlyIdx); XCTAssertNotNil(lateIdx) + if let earlyIdx, let lateIdx { + XCTAssertLessThan(earlyIdx, lateIdx, "earlier reply must render before the later one") + } + // Replies render as block quotes. + XCTAssertTrue(md.contains("> EARLY_REPLY"), "reply body should be quoted") + } + + // MARK: - Lists (table conversion) + + func test_givenSchemaDSL_whenRenderingList_thenColumnsFollowSchemaOrder() { + // Given a list whose schema declares Title before Year. + let input = MarkdownExporter.ListInput( + title: "Films", + description: "Sci-fi", + schemaDSL: "Title:text, Year:number", + rows: [row("r1", ["Title": .string("Dune"), "Year": .int(1965)])] + ) + + // When + let md = exporter.markdown(forList: input) + + // Then — heading, italic description, schema-ordered header + separator + row. + XCTAssertTrue(md.contains("# Films")) + XCTAssertTrue(md.contains("_Sci-fi_"), "description should render italic") + XCTAssertTrue(md.contains("| Title | Year |"), "columns follow schema order: \(md)") + XCTAssertTrue(md.contains("| --- | --- |")) + XCTAssertTrue(md.contains("| Dune | 1965 |")) + } + + func test_givenNoSchema_whenRenderingList_thenColumnsAreSortedUnionOfRowKeys() { + // Given no schema — columns must be derived from the rows. + let input = MarkdownExporter.ListInput( + title: "Ad hoc", + description: nil, + schemaDSL: nil, + rows: [ + row("r1", ["b": .string("2"), "a": .string("1")]), + row("r2", ["c": .string("3")]) + ] + ) + + // When + let md = exporter.markdown(forList: input) + + // Then — union of keys, deterministically sorted: a, b, c. + XCTAssertTrue(md.contains("| a | b | c |"), "fallback columns should be sorted union: \(md)") + } + + func test_givenCellWithPipeAndNewline_whenRenderingList_thenValueIsEscaped() { + // Given a value containing a pipe and a newline that would break a table. + let input = MarkdownExporter.ListInput( + title: "Escapes", + description: nil, + schemaDSL: "Note:text", + rows: [row("r1", ["Note": .string("a|b\nc")])] + ) + + // When + let md = exporter.markdown(forList: input) + + // Then — pipe backslash-escaped, newline collapsed to a space. + XCTAssertTrue(md.contains("| a\\|b c |"), "cell should be pipe-escaped and newline-collapsed: \(md)") + } + + func test_givenEmptyRows_whenRenderingList_thenHeaderOnlyTable() { + let input = MarkdownExporter.ListInput( + title: "Empty", + description: nil, + schemaDSL: "Title:text", + rows: [] + ) + let md = exporter.markdown(forList: input) + XCTAssertTrue(md.contains("| Title |")) + XCTAssertTrue(md.contains("| --- |")) + // No data rows beyond the header + separator. + let dataLines = md.split(separator: "\n").filter { $0.hasPrefix("|") } + XCTAssertEqual(dataLines.count, 2, "header + separator only, no data rows") + } + + func test_givenNoColumnsDerivable_whenRenderingList_thenExplanatoryLine() { + let input = MarkdownExporter.ListInput( + title: "Barren", + description: nil, + schemaDSL: nil, + rows: [] + ) + let md = exporter.markdown(forList: input) + XCTAssertTrue(md.contains("_No columns defined._"), "empty list should explain, not render an empty table") + XCTAssertFalse(md.contains("| --- |")) + } + + func test_givenMultipleLists_whenRendered_thenSeparatedByHorizontalRule() { + let a = MarkdownExporter.ListInput(title: "A", description: nil, schemaDSL: "X:text", rows: []) + let b = MarkdownExporter.ListInput(title: "B", description: nil, schemaDSL: "Y:text", rows: []) + let md = exporter.markdown(forLists: [a, b]) + XCTAssertTrue(md.contains("# A")) + XCTAssertTrue(md.contains("# B")) + XCTAssertTrue(md.contains("\n---\n"), "lists should be separated by a horizontal rule") + } + + // MARK: - Helpers (columns / escaping) + + func test_givenSchemaWithWhitespaceAndBareNames_whenParsingColumns_thenTrimmedNamesInOrder() { + // Given a messy DSL (extra spaces, a bare name with no type). + let cols = MarkdownExporter.columns(fromSchemaDSL: " First : text , Second ,Third:number", rows: []) + XCTAssertEqual(cols, ["First", "Second", "Third"], "names trimmed, types ignored, order preserved") + } + + func test_givenEmptySchemaString_whenParsingColumns_thenFallsBackToRowKeys() { + let rows = [row("r1", ["k": .string("v")])] + let cols = MarkdownExporter.columns(fromSchemaDSL: "", rows: rows) + XCTAssertEqual(cols, ["k"], "an empty schema string must fall back to row keys") + } + + func test_givenBackslashAndPipe_whenEscapingCell_thenBothEscaped() { + XCTAssertEqual(MarkdownExporter.escapeCell("a\\b|c"), "a\\\\b\\|c") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceM6Tests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceM6Tests.swift index 57f137c..85d6e0c 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceM6Tests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MessagesServiceM6Tests.swift @@ -35,7 +35,7 @@ final class MessagesServiceM6Tests: XCTestCase { let message = try await service.createPost( body: "hello", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) // Then — the post is created; no gate fires for a plain post. @@ -57,7 +57,7 @@ final class MessagesServiceM6Tests: XCTestCase { let message = try await service.createPost( body: "look", tags: [], visibility: .public, imageURLs: ["https://cdn/a.png"], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) // Then @@ -76,7 +76,7 @@ final class MessagesServiceM6Tests: XCTestCase { _ = try await service.createPost( body: "look", tags: [], visibility: .public, imageURLs: ["https://cdn/a.png"], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) XCTFail("Expected MessagesError.subscriberRequired") } catch let error as MessagesError { @@ -103,7 +103,7 @@ final class MessagesServiceM6Tests: XCTestCase { let message = try await service.createPost( body: "later", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: when, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) // Then — created, but not written to the by-id cache (not yet published). @@ -122,7 +122,7 @@ final class MessagesServiceM6Tests: XCTestCase { _ = try await service.createPost( body: "later", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: Date(), - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) XCTFail("Expected MessagesError.subscriberRequired") } catch let error as MessagesError { @@ -144,7 +144,7 @@ final class MessagesServiceM6Tests: XCTestCase { _ = try await service.createPost( body: "fan out", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: true, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: true, crossPostToLinkedIn: false, crossPostToTwitter: false ) XCTFail("Expected MessagesError.subscriberRequired") } catch let error as MessagesError { @@ -164,7 +164,7 @@ final class MessagesServiceM6Tests: XCTestCase { let message = try await service.createPost( body: "fan out", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: ["p-1", "p-2"], crossPostToBluesky: false, crossPostToLinkedIn: true + mastodonProviderIds: ["p-1", "p-2"], crossPostToBluesky: false, crossPostToLinkedIn: true, crossPostToTwitter: false ) // Then @@ -173,6 +173,49 @@ final class MessagesServiceM6Tests: XCTestCase { XCTAssertEqual(recorded.count, 1) } + // MARK: - createPost cross-post gate (G7 — X / Twitter) + + func test_givenFreeAccountCrossPostingToTwitter_whenCreating_thenThrowsSubscriberRequiredBeforeCall() async throws { + // Given — invalid input for a free account: an X-only cross-post must + // be gated by the same `.crossPosting` entitlement as Bluesky / + // LinkedIn, before any HTTP call is made. + let api = StubAPIClient() + let service = freeService(api) + + // When / Then + do { + _ = try await service.createPost( + body: "hello X", tags: [], visibility: .public, + imageURLs: [], videoURLs: [], scheduledAt: nil, + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: true + ) + XCTFail("Expected MessagesError.subscriberRequired") + } catch let error as MessagesError { + XCTAssertEqual(error, .subscriberRequired(.crossPosting)) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenSubscriberCrossPostingToTwitter_whenCreating_thenPosts() async throws { + // Given — happy path (G7): a subscriber fans out to X only. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.messageObject(id: "m-x")) + let service = subscriberService(api) + + // When + let message = try await service.createPost( + body: "hello X", tags: [], visibility: .public, + imageURLs: [], videoURLs: [], scheduledAt: nil, + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: true + ) + + // Then + XCTAssertEqual(message.id, "m-x") + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) + } + // MARK: - createPost API failure + boundary func test_givenCreatePostUpstreamFails_whenCreating_thenSurfacesAPIError() async throws { @@ -186,7 +229,7 @@ final class MessagesServiceM6Tests: XCTestCase { _ = try await service.createPost( body: "look", tags: [], visibility: .public, imageURLs: ["https://cdn/a.png"], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) XCTFail("Expected an APIError") } catch let error as APIError { @@ -204,7 +247,7 @@ final class MessagesServiceM6Tests: XCTestCase { let message = try await service.createPost( body: "", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) // Then @@ -427,7 +470,7 @@ final class MessagesServiceM6Tests: XCTestCase { let message = try await service.createPost( body: "look", tags: [], visibility: .public, imageURLs: ["https://cdn/a.png"], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) // Then — the live provider grants media, so the post is created. @@ -449,7 +492,7 @@ final class MessagesServiceM6Tests: XCTestCase { _ = try await service.createPost( body: "look", tags: [], visibility: .public, imageURLs: ["https://cdn/a.png"], videoURLs: [], scheduledAt: nil, - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) XCTFail("Expected MessagesError.subscriberRequired") } catch let error as MessagesError { @@ -476,7 +519,7 @@ final class MessagesServiceM6Tests: XCTestCase { _ = try await service.createPost( body: "later", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: Date(), - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) XCTFail("Expected MessagesError.subscriberRequired while free") } catch let error as MessagesError { @@ -492,7 +535,7 @@ final class MessagesServiceM6Tests: XCTestCase { let message = try await service.createPost( body: "later", tags: [], visibility: .public, imageURLs: [], videoURLs: [], scheduledAt: Date(), - mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false + mastodonProviderIds: [], crossPostToBluesky: false, crossPostToLinkedIn: false, crossPostToTwitter: false ) XCTAssertEqual(message.id, "m-flip") recorded = await api.recorded diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ModerationServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ModerationServiceTests.swift new file mode 100644 index 0000000..31d5cf2 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/ModerationServiceTests.swift @@ -0,0 +1,171 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `ModerationService` (the-gaps.md G2). Quartet per +/// public method: happy + invalid + failure + empty/boundary. +final class ModerationServiceTests: XCTestCase { + + // MARK: - blockedUsers + + func test_givenBlockedUsers_whenLoading_thenMapsRowsAndHitsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"blockedUsers":[{"id":"u1","username":"spammer","displayName":"Spam","avatar":"https://cdn/x.png"}], + "pagination":{"total":1,"limit":50,"offset":0,"hasMore":false}} + """#) + let service = ModerationService(api: api) + + let users = try await service.blockedUsers() + + XCTAssertEqual(users.map(\.id), ["u1"]) + XCTAssertEqual(users.first?.username, "spammer") + XCTAssertEqual(users.first?.avatarURL?.absoluteString, "https://cdn/x.png") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "GET") + XCTAssertEqual(recorded.first?.path, "/api/user/blocks") + } + + func test_givenServerFailure_whenLoadingBlocked_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = ModerationService(api: api) + + do { + _ = try await service.blockedUsers() + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + func test_givenNoBlocked_whenLoading_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"blockedUsers":[],"pagination":{"total":0,"limit":50,"offset":0,"hasMore":false}}"#) + let service = ModerationService(api: api) + + let users = try await service.blockedUsers() + + XCTAssertTrue(users.isEmpty) + } + + // MARK: - mutedUsers + + func test_givenMutedUsers_whenLoading_thenMapsRowsAndHitsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"mutedUsers":[{"id":"u2"}],"pagination":{"total":1,"limit":50,"offset":0,"hasMore":false}}"#) + let service = ModerationService(api: api) + + let users = try await service.mutedUsers() + + XCTAssertEqual(users.map(\.id), ["u2"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/user/mutes") + } + + // MARK: - block / unblock / mute / unmute + + func test_givenUsername_whenBlocking_thenPostsToBlockPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"ok":true}"#) + let service = ModerationService(api: api) + + try await service.block(username: "ada") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/users/ada/block") + } + + func test_givenUsername_whenUnblocking_thenDeletesBlockPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{}"#) + let service = ModerationService(api: api) + + try await service.unblock(username: "ada") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/users/ada/block") + } + + func test_givenUsername_whenMuting_thenPostsToMutePath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{}"#) + let service = ModerationService(api: api) + + try await service.mute(username: "ada") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/users/ada/mute") + XCTAssertEqual(recorded.first?.method, "POST") + } + + func test_givenBlockFails_whenBlocking_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: "cannot block yourself")) + let service = ModerationService(api: api) + + do { + try await service.block(username: "me") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "cannot block yourself")) + } + } + + // MARK: - reporting + + func test_givenReason_whenReportingUser_thenPostsToReportPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"ok":true}"#) + let service = ModerationService(api: api) + + try await service.reportUser(username: "ada", reason: .spam, detail: " ") + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/users/ada/report") + } + + func test_givenReason_whenReportingMessage_thenPostsToMessageReportPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"ok":true}"#) + let service = ModerationService(api: api) + + try await service.reportMessage(id: "m1", reason: .harassment) + + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/messages/m1/report") + } + + // MARK: - isBlocking (derived) + + func test_givenUserInBlockList_whenCheckingIsBlocking_thenTrue() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"blockedUsers":[{"id":"u1","username":"Spammer"}],"pagination":{"total":1,"limit":100,"offset":0,"hasMore":false}}"#) + let service = ModerationService(api: api) + + let blocking = try await service.isBlocking(username: "spammer") // case-insensitive + + XCTAssertTrue(blocking) + } + + func test_givenUserNotInBlockList_whenCheckingIsBlocking_thenFalse() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"blockedUsers":[],"pagination":{"total":0,"limit":100,"offset":0,"hasMore":false}}"#) + let service = ModerationService(api: api) + + let blocking = try await service.isBlocking(username: "ghost") + + XCTAssertFalse(blocking) + } + + // MARK: - ReportReason + + func test_givenReportReason_whenReadingRawValues_thenMatchAPIContract() { + XCTAssertEqual(ReportReason.allCases.map(\.rawValue), + ["harassment", "spam", "misinformation", "inappropriate", "other"]) + XCTAssertEqual(ReportReason.inappropriate.label, "Inappropriate content") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift index b95ca0f..b7baba2 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift @@ -34,8 +34,9 @@ final class SchemaDSLTests: XCTestCase { // MARK: - Every type func test_givenAllSupportedTypes_whenParsing_thenEachTypeIsRecognised() throws { - // Given — one column per supported `SchemaFieldType`. - let source = "A:text, B:number, C:boolean, D:date, E:url, F:email" + // Given — one column per supported `SchemaFieldType`. `select` needs + // an inline option set; the rest are bare tokens. + let source = "A:text, B:number, C:boolean, D:date, E:url, F:email, G:select(x|y), H:markdown" // When let schema = try SchemaDSL.parse(source) @@ -246,4 +247,125 @@ final class SchemaDSLTests: XCTestCase { XCTAssertEqual(schema.field(named: "Year")?.type, .number) XCTAssertNil(schema.field(named: "Missing")) } + + // MARK: - select options (§1.1) + + func test_givenSelectWithOptions_whenParsing_thenCapturesOrderedOptions() throws { + // Happy path — the ordered option set is captured on `enumValues`. + let schema = try SchemaDSL.parse("Priority:select(low|med|high)") + + let field = try XCTUnwrap(schema.fields.first) + XCTAssertEqual(field.type, .select) + XCTAssertEqual(field.enumValues, ["low", "med", "high"]) + } + + func test_givenSelectAlongsideOtherFields_whenParsing_thenSplitsFieldsCorrectly() throws { + // The pipe-delimited option list must not be mistaken for a field + // boundary — commas still separate fields, pipes separate options. + let schema = try SchemaDSL.parse("Title:text, Priority:select(low|med|high), Done:boolean") + + XCTAssertEqual(schema.fields.map(\.name), ["Title", "Priority", "Done"]) + XCTAssertEqual(schema.fields.map(\.type), [.text, .select, .boolean]) + XCTAssertEqual(schema.field(named: "Priority")?.enumValues, ["low", "med", "high"]) + } + + func test_givenSelectWithWhitespaceInOptions_whenParsing_thenTrimsEachOption() throws { + // Whitespace tolerance extends into the option list. + let schema = try SchemaDSL.parse("P:select( low | med | high )") + + XCTAssertEqual(schema.field(named: "P")?.enumValues, ["low", "med", "high"]) + } + + func test_givenSelectSchema_whenSerializedAndReparsed_thenRoundTripsOptions() throws { + // Round-trip: options survive serialize → parse with order intact. + let original = ListSchema(fields: [ + SchemaField(name: "Title", type: .text), + SchemaField(name: "Priority", type: .select, enumValues: ["low", "med", "high"]), + SchemaField(name: "Notes", type: .markdown) + ]) + + let serialized = SchemaDSL.serialize(original) + let reparsed = try SchemaDSL.parse(serialized) + + XCTAssertEqual(serialized, "Title:text, Priority:select(low|med|high), Notes:markdown") + XCTAssertEqual(reparsed, original) + } + + func test_givenSingleOptionSelect_whenParsing_thenAcceptsOneOption() throws { + // Boundary — a single option is valid (a degenerate but legal set). + let schema = try SchemaDSL.parse("Status:select(active)") + + XCTAssertEqual(schema.field(named: "Status")?.enumValues, ["active"]) + } + + // MARK: - select option validation (§1.1) + + func test_givenSelectWithEmptyOptionList_whenParsing_thenThrowsEmptySelectOptions() { + // Invalid input — `select()` declares no options. + XCTAssertThrowsError(try SchemaDSL.parse("P:select()")) { error in + XCTAssertEqual(error as? SchemaDSLError, .emptySelectOptions(field: "P")) + } + } + + func test_givenSelectWithNoParens_whenParsing_thenThrowsEmptySelectOptions() { + // Invalid input — a bare `select` with no `(...)` at all. + XCTAssertThrowsError(try SchemaDSL.parse("P:select")) { error in + XCTAssertEqual(error as? SchemaDSLError, .emptySelectOptions(field: "P")) + } + } + + func test_givenSelectWithBlankOption_whenParsing_thenThrowsEmptySelectOptions() { + // Boundary — `a||b` yields a blank middle option; rejected. + XCTAssertThrowsError(try SchemaDSL.parse("P:select(a||b)")) { error in + XCTAssertEqual(error as? SchemaDSLError, .emptySelectOptions(field: "P")) + } + } + + func test_givenSelectWithDuplicateOptions_whenParsing_thenThrowsDuplicateSelectOption() { + // Invalid input — the same option twice. + XCTAssertThrowsError(try SchemaDSL.parse("P:select(low|med|low)")) { error in + XCTAssertEqual( + error as? SchemaDSLError, + .duplicateSelectOption(field: "P", option: "low") + ) + } + } + + func test_givenNonSelectTypeWithOptions_whenParsing_thenThrowsInvalidFieldSyntax() { + // Invalid input — options on a type that does not carry them. + XCTAssertThrowsError(try SchemaDSL.parse("P:text(a|b)")) { error in + XCTAssertEqual(error as? SchemaDSLError, .invalidFieldSyntax(rawField: "P:text(a|b)")) + } + } + + func test_givenUnclosedSelectParen_whenParsing_thenThrowsInvalidFieldSyntax() { + // Invalid input — missing closing paren. + XCTAssertThrowsError(try SchemaDSL.parse("P:select(a|b")) { error in + guard case .invalidFieldSyntax = error as? SchemaDSLError else { + return XCTFail("Expected invalidFieldSyntax, got \(error)") + } + } + } + + // MARK: - markdown (§1.1) + + func test_givenMarkdownField_whenParsing_thenParsesLikeTextWithNoOptions() throws { + // markdown is a long-text type: no options, recognised by token. + let schema = try SchemaDSL.parse("Body:markdown") + + let field = try XCTUnwrap(schema.fields.first) + XCTAssertEqual(field.type, .markdown) + XCTAssertNil(field.enumValues) + } + + func test_givenMarkdownWithOptions_whenParsing_thenThrowsInvalidFieldSyntax() { + // Boundary — markdown carries no options, so a `(...)` suffix is + // rejected the same way as any other non-select type. + XCTAssertThrowsError(try SchemaDSL.parse("Body:markdown(a|b)")) { error in + XCTAssertEqual( + error as? SchemaDSLError, + .invalidFieldSyntax(rawField: "Body:markdown(a|b)") + ) + } + } } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SearchServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SearchServiceTests.swift new file mode 100644 index 0000000..75df6df --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SearchServiceTests.swift @@ -0,0 +1,143 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `SearchService` (the-gaps.md G5). Quartet per public +/// method: happy + invalid/short-circuit + failure + empty/boundary. +final class SearchServiceTests: XCTestCase { + + private func messageEnvelope(ids: [String]) -> String { + let rows = ids.map { id in + #""" + {"id":"\#(id)","content":"c-\#(id)","publiclyVisible":true,"userId":"u-1", + "createdAt":"2026-07-31T00:00:00Z","updatedAt":"2026-07-31T00:00:00Z", + "digCount":0,"pushCount":0,"dugByMe":false, + "user":{"id":"u-1","username":"ada","displayName":"Ada"}} + """# + }.joined(separator: ",") + return "{\"messages\":[\(rows)]}" + } + + // MARK: - messages + + func test_givenHits_whenSearchingMessages_thenMapsRowsAndSendsQuery() async throws { + let api = StubAPIClient() + await api.enqueue(json: messageEnvelope(ids: ["m1", "m2"])) + let service = SearchService(api: api) + + let results = try await service.messages(query: "hello") + + XCTAssertEqual(results.map(\.id), ["m1", "m2"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "GET") + XCTAssertEqual(recorded.first?.path, "/api/messages/search") + XCTAssertEqual(recorded.first?.query["q"], "hello") + } + + func test_givenBlankQuery_whenSearchingMessages_thenReturnsEmptyWithoutRequest() async throws { + let api = StubAPIClient() + let service = SearchService(api: api) + + let results = try await service.messages(query: " ") + + XCTAssertTrue(results.isEmpty) + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A blank query must not hit the network") + } + + func test_givenServerFailure_whenSearchingMessages_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = SearchService(api: api) + + do { + _ = try await service.messages(query: "hello") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + func test_givenNoHits_whenSearchingMessages_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"messages":[]}"#) + let service = SearchService(api: api) + + let results = try await service.messages(query: "zzz") + + XCTAssertTrue(results.isEmpty) + } + + // MARK: - lists + + func test_givenHits_whenSearchingLists_thenMapsRows() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"lists":[{"id":"l1","title":"Bikes"}],"pagination":{"total":1,"limit":20,"offset":0,"hasMore":false}}"#) + let service = SearchService(api: api) + + let results = try await service.lists(query: "bike") + + XCTAssertEqual(results.map(\.id), ["l1"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/lists/search") + XCTAssertEqual(recorded.first?.query["q"], "bike") + } + + func test_givenBlankQuery_whenSearchingLists_thenReturnsEmptyWithoutRequest() async throws { + let api = StubAPIClient() + let service = SearchService(api: api) + + let results = try await service.lists(query: "") + + XCTAssertTrue(results.isEmpty) + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // MARK: - documents + + func test_givenHits_whenSearchingDocuments_thenMapsRows() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"documents":[{"id":"d1","title":"Notes","content":"body"}]}"#) + let service = SearchService(api: api) + + let results = try await service.documents(query: "notes") + + XCTAssertEqual(results.map(\.id), ["d1"]) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents/search") + } + + func test_givenDocumentSearchFails_whenSearching_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "nope")) + let service = SearchService(api: api) + + do { + _ = try await service.documents(query: "notes") + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .notFound(serverMessage: "nope")) + } + } + + // MARK: - all (fan-out) + + func test_givenBlankQuery_whenSearchingAll_thenReturnsEmptyWithoutRequest() async throws { + let api = StubAPIClient() + let service = SearchService(api: api) + + let results = try await service.all(query: " \n ") + + XCTAssertEqual(results, .empty) + XCTAssertTrue(results.isEmpty) + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenEmptyResults_whenBuildingSearchResults_thenIsEmptyAndCountsZero() { + let results = SearchResults.empty + XCTAssertTrue(results.isEmpty) + XCTAssertEqual(results.totalCount, 0) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SharingServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SharingServiceTests.swift new file mode 100644 index 0000000..a0b841d --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SharingServiceTests.swift @@ -0,0 +1,163 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// BDD-named coverage for `SharingService` (the-gaps.md G3). Quartet per public +/// method: happy + invalid + failure + empty/boundary. Includes the subscriber +/// gate on create. +final class SharingServiceTests: XCTestCase { + + private func subscriberService(_ api: StubAPIClient) -> SharingService { + SharingService(api: api, entitlements: EntitlementsService(customerStatus: .subscriber)) + } + + // MARK: - create (subscriber-gated) + + func test_givenSubscriber_whenCreatingListLink_thenMapsLinkAndPostsPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"token":"tok9","url":"https://x/s/tok9","role":"collaborator","expiresAt":null}"#) + let service = subscriberService(api) + + let link = try await service.createListShareLink(listId: "l1", role: .collaborator) + + XCTAssertEqual(link.token, "tok9") + XCTAssertEqual(link.role, .collaborator) + XCTAssertEqual(link.url?.absoluteString, "https://x/s/tok9") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/lists/l1/share-links") + } + + func test_givenFreeUser_whenCreatingListLink_thenThrowsSubscriberRequiredWithoutRequest() async throws { + let api = StubAPIClient() + let service = SharingService(api: api, entitlements: EntitlementsService(customerStatus: .free)) + + do { + _ = try await service.createListShareLink(listId: "l1", role: .watcher) + XCTFail("Expected subscriberRequired") + } catch let error as SharingError { + XCTAssertEqual(error, .subscriberRequired) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "The subscriber gate must short-circuit before any HTTP call") + } + + func test_givenSubscriber_whenCreatingDocumentLink_thenPostsDocumentPath() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"token":"tokD","role":"manager","expiresAt":null}"#) + let service = subscriberService(api) + + let link = try await service.createDocumentShareLink(documentId: "d1", role: .manager) + + XCTAssertEqual(link.role, .manager) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents/d1/share-links") + } + + // MARK: - list + + func test_givenLinks_whenListingListLinks_thenMapsRows() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"shareLinks":[{"token":"t1","role":"watcher","expiresAt":null, + "createdAt":"2026-07-31T23:07:24.019Z","revokedAt":null,"url":"https://x/s/t1"}]} + """#) + let service = subscriberService(api) + + let links = try await service.listShareLinks(listId: "l1") + + XCTAssertEqual(links.map(\.token), ["t1"]) + XCTAssertFalse(links.first?.isRevoked ?? true) + } + + func test_givenEmpty_whenListingLinks_thenReturnsEmpty() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"shareLinks":[]}"#) + let service = subscriberService(api) + + let links = try await service.listShareLinks(listId: "l1") + + XCTAssertTrue(links.isEmpty) + } + + func test_givenServerFailure_whenListingLinks_thenThrows() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = subscriberService(api) + + do { + _ = try await service.listShareLinks(listId: "l1") + XCTFail("Expected APIError") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - resolve + + func test_givenListResolve_whenResolving_thenMapsRoleAndListResource() async throws { + let api = StubAPIClient() + await api.enqueue(json: #""" + {"role":"watcher","canClaim":false,"needsAuth":false, + "list":{"id":"l1","title":"Bikes","description":null,"isPublic":false,"updatedAt":null}} + """#) + let service = subscriberService(api) + + let resolved = try await service.resolveListShare(token: "t1") + + XCTAssertEqual(resolved.role, .watcher) + XCTAssertFalse(resolved.canClaim) + XCTAssertEqual(resolved.resource, .list(id: "l1", title: "Bikes", description: nil, isPublic: false)) + } + + func test_givenDocumentResolve_whenResolving_thenMapsDocumentResource() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"role":"collaborator","canClaim":true,"needsAuth":false,"document":{"id":"d1","title":"Notes","isPublic":false}}"#) + let service = subscriberService(api) + + let resolved = try await service.resolveDocumentShare(token: "t1") + + XCTAssertTrue(resolved.canClaim) + XCTAssertEqual(resolved.resource, .document(id: "d1", title: "Notes", isPublic: false)) + } + + // MARK: - revoke / claim + + func test_givenToken_whenRevoking_thenDeletesAndReturnsFlag() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"revoked":true}"#) + let service = subscriberService(api) + + let revoked = try await service.revokeListShareLink(listId: "l1", token: "t1") + + XCTAssertTrue(revoked) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/lists/l1/share-links/t1") + } + + func test_givenToken_whenClaimingListShare_thenMapsResourceIdAndRole() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"listId":"l1","role":"collaborator"}"#) + let service = subscriberService(api) + + let claim = try await service.claimListShare(token: "t1") + + XCTAssertEqual(claim.resourceId, "l1") + XCTAssertEqual(claim.role, .collaborator) + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.method, "POST") + XCTAssertEqual(recorded.first?.path, "/api/lists/shared/t1") + } + + // MARK: - ShareRole + + func test_givenShareRoles_whenReadingLabelsAndCapabilities_thenMatchContract() { + XCTAssertEqual(ShareRole.allCases.map(\.rawValue), ["watcher", "collaborator", "manager"]) + XCTAssertEqual(ShareRole.watcher.label, "Viewer") + XCTAssertEqual(ShareRole.manager.label, "Admin") + XCTAssertFalse(ShareRole.watcher.canEdit) + XCTAssertTrue(ShareRole.collaborator.canEdit) + XCTAssertTrue(ShareRole.manager.canManage) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift index 7e94cb2..b5f9174 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SocialServiceTests.swift @@ -178,12 +178,41 @@ final class SocialServiceTests: XCTestCase { } } - // MARK: - profile (decision 0002 — public-profile fallback) + // MARK: - profile (D2 public endpoint + decision-0002 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. + func test_givenPublicProfileEndpoint_whenLoadingProfile_thenMapsRichProfile() async throws { + // Given — the dedicated `GET /api/users/{username}` endpoint (D2). let api = StubAPIClient() + await api.enqueue(json: #""" + {"id":"user-ada","username":"ada","displayName":"Ada Lovelace", + "avatar":"https://cdn/ada.png","headerImage":null,"bio":"Countess of Computing", + "joinedAt":"2026-03-23T23:23:59.755Z","isPrivate":false, + "followerCount":42,"followingCount":7,"publicMessageCount":10,"publicListCount":2} + """#) + let service = SocialService(api: api) + + // When + let profile = try await service.profile(username: "ada") + + // Then — the rich payload maps straight through (no counts stitch). + XCTAssertEqual(profile.id, "user-ada") + XCTAssertEqual(profile.username, "ada") + XCTAssertEqual(profile.bio, "Countess of Computing") + XCTAssertEqual(profile.followerCount, 42) + XCTAssertEqual(profile.followingCount, 7) + XCTAssertNotNil(profile.joinedAt) + + // And — it hits the public-profile path, not the message fallback. + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) + XCTAssertEqual(recorded.first?.path, "/api/users/ada") + } + + func test_givenProfileEndpoint404_whenLoadingProfile_thenFallsBackToEmbeddedAuthor() async throws { + // Given — a pre-migration server that 404s the profile endpoint; the + // decision-0002 fallback derives identity from the embedded author. + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "no profile endpoint")) await api.enqueue(json: Fixtures.paginatedMessages(ids: ["m-1"])) let service = SocialService(api: api) @@ -194,20 +223,17 @@ final class SocialServiceTests: XCTestCase { 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. + // And — the fallback hit the tiny public-messages page. 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") + XCTAssertEqual(recorded.last?.path, "/api/user/ada/messages") + XCTAssertEqual(recorded.last?.query["limit"], "1") } - 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`. + func test_givenFallbackAuthor_whenLoadingProfile_thenRicherFieldsAreNil() async throws { + // Given — the fallback path cannot populate bio/counts/joinedAt. let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "no profile endpoint")) await api.enqueue(json: Fixtures.paginatedMessages(ids: ["m-1"])) let service = SocialService(api: api) @@ -222,10 +248,10 @@ final class SocialServiceTests: XCTestCase { XCTAssertFalse(profile.isPrivate) } - func test_givenUsernameWithNoMessages_whenLoadingProfile_thenThrowsProfileUnavailable() async throws { - // Given — boundary / empty path: zero public messages means no - // embedded author to project from. + func test_givenFallbackWithNoMessages_whenLoadingProfile_thenThrowsProfileUnavailable() async throws { + // Given — endpoint 404s AND the user has zero public messages. let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "no profile endpoint")) await api.enqueue(json: Fixtures.paginatedMessages(ids: [])) let service = SocialService(api: api) @@ -238,10 +264,10 @@ final class SocialServiceTests: XCTestCase { } } - func test_givenAPIReturns404_whenLoadingProfile_thenThrowsAPIError() async throws { - // Given — upstream API failure: username does not exist. + func test_givenProfileEndpointServerError_whenLoadingProfile_thenThrowsWithoutFallback() async throws { + // Given — a non-404 failure must propagate, not trigger the fallback. let api = StubAPIClient() - await api.enqueue(failure: .notFound(serverMessage: "user not found")) + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) let service = SocialService(api: api) // When / Then @@ -249,8 +275,12 @@ final class SocialServiceTests: XCTestCase { _ = try await service.profile(username: "nobody") XCTFail("Expected an APIError") } catch let error as APIError { - XCTAssertEqual(error, .notFound(serverMessage: "user not found")) + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) } + // And — no fallback message call was made. + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) + XCTAssertEqual(recorded.first?.path, "/api/users/nobody") } func test_givenAPIReturnsMalformedPayload_whenLoadingProfile_thenThrowsDecoding() async throws { diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DirectMessageDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DirectMessageDTO.swift new file mode 100644 index 0000000..2438538 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DirectMessageDTO.swift @@ -0,0 +1,134 @@ +import Foundation + +// MARK: - Direct Message DTOs (the-gaps.md G1) +// +// Shapes verified live 2026-07-31 via an authorized recon DM (sent from the +// test account, captured, then trashed): +// +// POST /api/dm -> { "message": DirectMessageDTO } (201) +// GET /api/dm?folder=inbox|sent|deleted&cursor= +// -> { "items": [DirectMessageDTO], "nextCursor": String? } +// GET /api/dm/thread/{username} -> { "items": [...], "olderCursor": String?, +// "isMutual": Bool, "isBlocked": Bool, +// "otherUser": UserSummaryDTO } +// GET /api/dm/recipients -> { "recipients": [UserSummaryDTO] } +// GET /api/dm/unread-count -> { "count": Int } +// POST /api/dm/{id}/{read,trash,restore} -> { "ok": true } +// +// `sender` / `recipient` reuse the compact `UserSummaryDTO` shape. + +/// A single direct message. +public struct DirectMessageDTO: Decodable, Sendable, Equatable, Identifiable { + public let id: String + /// `senderId:recipientId` conversation key. + public let pairKey: String? + public let senderId: String + public let recipientId: String + public let body: String + public let imageUrls: [String]? + public let createdAt: Date + /// `nil` while unread; set to the read timestamp once the recipient opens it. + public let readAt: Date? + public let sender: UserSummaryDTO? + public let recipient: UserSummaryDTO? + /// Server-truncated preview string. + public let preview: String? + + public init( + id: String, + pairKey: String? = nil, + senderId: String, + recipientId: String, + body: String, + imageUrls: [String]? = nil, + createdAt: Date, + readAt: Date? = nil, + sender: UserSummaryDTO? = nil, + recipient: UserSummaryDTO? = nil, + preview: String? = nil + ) { + self.id = id + self.pairKey = pairKey + self.senderId = senderId + self.recipientId = recipientId + self.body = body + self.imageUrls = imageUrls + self.createdAt = createdAt + self.readAt = readAt + self.sender = sender + self.recipient = recipient + self.preview = preview + } +} + +/// `POST /api/dm` response — the created message wrapped under `message`. +public struct DMCreateResponse: Decodable, Sendable, Equatable { + public let message: DirectMessageDTO + public init(message: DirectMessageDTO) { self.message = message } +} + +/// `GET /api/dm?folder=…` response — a cursor-paginated folder listing. +public struct DMFolderPage: Decodable, Sendable, Equatable { + public let items: [DirectMessageDTO] + public let nextCursor: String? + public init(items: [DirectMessageDTO], nextCursor: String? = nil) { + self.items = items + self.nextCursor = nextCursor + } +} + +/// `GET /api/dm/thread/{username}` (and `/updates`) response. +public struct DMThreadResponse: Decodable, Sendable, Equatable { + public let items: [DirectMessageDTO] + public let olderCursor: String? + public let isMutual: Bool? + public let isBlocked: Bool? + public let otherUser: UserSummaryDTO? + + public init( + items: [DirectMessageDTO], + olderCursor: String? = nil, + isMutual: Bool? = nil, + isBlocked: Bool? = nil, + otherUser: UserSummaryDTO? = nil + ) { + self.items = items + self.olderCursor = olderCursor + self.isMutual = isMutual + self.isBlocked = isBlocked + self.otherUser = otherUser + } +} + +/// `GET /api/dm/recipients` response — the users the caller may DM. +public struct DMRecipientsResponse: Decodable, Sendable, Equatable { + public let recipients: [UserSummaryDTO] + public init(recipients: [UserSummaryDTO]) { self.recipients = recipients } +} + +/// `GET /api/dm/unread-count` response. +public struct DMUnreadCountResponse: Decodable, Sendable, Equatable { + public let count: Int + public init(count: Int) { self.count = count } +} + +/// Body for `POST /api/dm`: `{ recipientId, body, imageUrls? }`. +public struct SendDirectMessageRequest: Encodable, Sendable, Equatable { + public let recipientId: String + public let body: String + public let imageUrls: [String]? + + public init(recipientId: String, body: String, imageUrls: [String]? = nil) { + self.recipientId = recipientId + self.body = body + self.imageUrls = imageUrls + } +} + +/// Decode-safe acknowledgement for the DM actions (`read` / `trash` / `restore`). +/// The service sends these via `sendVoid`, so the body is ignored; `ok` is +/// optional so the type decodes regardless of the exact success payload. +public struct DMActionResponse: Decodable, Sendable, Equatable { + public let ok: Bool? + public init(ok: Bool? = nil) { self.ok = ok } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentTemplateDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentTemplateDTO.swift new file mode 100644 index 0000000..11e64cd --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/DocumentTemplateDTO.swift @@ -0,0 +1,50 @@ +import Foundation + +// MARK: - Server document-template DTOs (the-gaps.md G12) +// +// The user's own saved template documents (distinct from the client-side +// built-in catalog). Shapes verified live 2026-07-31: +// +// GET /api/documents/templates +// -> { folderCreated: Bool, templatesFolderId: String, +// templates: [{ id, title, relativePath }] } +// POST /api/documents/from-template { templateDocumentId } -> 201 (empty body) +// POST /api/documents/templates/seed-defaults -> seeds defaults + +/// A reference to one server-side template document. +public struct DocumentTemplateDTO: Decodable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let relativePath: String? + + public init(id: String, title: String, relativePath: String? = nil) { + self.id = id + self.title = title + self.relativePath = relativePath + } +} + +/// `GET /api/documents/templates` response. Ensures the `_templates` folder +/// exists (`folderCreated` is `true` the first time) and lists the templates. +public struct DocumentTemplatesResponse: Decodable, Sendable, Equatable { + public let folderCreated: Bool? + public let templatesFolderId: String? + public let templates: [DocumentTemplateDTO] + + public init(folderCreated: Bool? = nil, templatesFolderId: String? = nil, templates: [DocumentTemplateDTO]) { + self.folderCreated = folderCreated + self.templatesFolderId = templatesFolderId + self.templates = templates + } +} + +/// Body for `POST /api/documents/from-template`. The field name is +/// `templateDocumentId` (verified live — a bare `templateId` returns +/// `400 "templateDocumentId is required."`). +public struct CreateFromTemplateRequest: Encodable, Sendable, Equatable { + public let templateDocumentId: String + + public init(templateDocumentId: String) { + self.templateDocumentId = templateDocumentId + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/LinkedInDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/LinkedInDTO.swift new file mode 100644 index 0000000..292e3c5 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/LinkedInDTO.swift @@ -0,0 +1,42 @@ +import Foundation + +// MARK: - LinkedIn posting-target DTOs (the-gaps.md G11a) +// +// Shapes verified live 2026-07-31 (read-only): +// GET /api/linkedin/posting-targets +// -> { targets: [{ kind, label, avatarUrl, enabled }], orgScopeMissing } +// GET /api/linkedin/targets +// -> { targets: [{ kind, label, avatarUrl }] } +// +// `kind` is "personal" or "org". Org targets require the LinkedIn org scope, +// which is not enabled for every tenant (`orgScopeMissing` / status +// `orgScopesEnabled:false`) — the org half is tracked separately as G11b. + +/// A LinkedIn posting target the user can cross-post to. +public struct LinkedInTargetDTO: Decodable, Sendable, Equatable { + public let kind: String + public let label: String + public let avatarUrl: String? + /// Present on `posting-targets` (whether the target is currently enabled). + public let enabled: Bool? + + public init(kind: String, label: String, avatarUrl: String? = nil, enabled: Bool? = nil) { + self.kind = kind + self.label = label + self.avatarUrl = avatarUrl + self.enabled = enabled + } +} + +/// `GET /api/linkedin/posting-targets` response. +public struct LinkedInPostingTargetsResponse: Decodable, Sendable, Equatable { + public let targets: [LinkedInTargetDTO] + /// `true` when the LinkedIn org scope is not granted, so org page targets + /// are unavailable (G11b — deferred). + public let orgScopeMissing: Bool? + + public init(targets: [LinkedInTargetDTO], orgScopeMissing: Bool? = nil) { + self.targets = targets + self.orgScopeMissing = orgScopeMissing + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListFolderDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListFolderDTO.swift new file mode 100644 index 0000000..836faed --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ListFolderDTO.swift @@ -0,0 +1,66 @@ +import Foundation + +// MARK: - List Folder DTOs (the-gaps.md G6) +// +// Hierarchical folders for *lists* (distinct from document folders). Envelope +// verified live 2026-07-31: `GET /api/folders` → `{ folders: [...] }` (a flat +// array; clients rebuild the tree from each row's `parentId`). The folder rows +// were empty in the probe, so fields beyond `id`/`name` are modelled optional. + +/// A single list folder. Nesting is expressed via `parentId` (`nil` at root). +public struct ListFolderDTO: Decodable, Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let parentId: String? + public let createdAt: Date? + public let updatedAt: Date? + + public init( + id: String, + name: String, + parentId: String? = nil, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.name = name + self.parentId = parentId + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +/// `GET /api/folders` — `{ folders: [ListFolderDTO] }` (flat array, no pagination). +public struct ListFoldersResponse: Decodable, Sendable, Equatable { + public let folders: [ListFolderDTO] + + public init(folders: [ListFolderDTO]) { + self.folders = folders + } +} + +/// Body for `POST /api/folders`. `name` is 1–80 chars (validated in the domain +/// layer); `parentId` is `nil` for a root folder. +public struct CreateListFolderRequest: Encodable, Sendable, Equatable { + public let name: String + public let parentId: String? + + public init(name: String, parentId: String? = nil) { + self.name = name + self.parentId = parentId + } +} + +/// Body for `PUT /api/folders/{id}` — rename (`name`) and/or move (`parentId`). +/// Absent fields are left unchanged by the server. (Moving to the root, which +/// requires an explicit `parentId: null`, is a documented follow-up — Swift +/// optional encoding omits nil rather than emitting `null`.) +public struct UpdateListFolderRequest: Encodable, Sendable, Equatable { + public let name: String? + public let parentId: String? + + public init(name: String? = nil, parentId: String? = nil) { + self.name = name + self.parentId = parentId + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift index 158788c..02efe64 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift @@ -240,6 +240,16 @@ public struct CreateMessageRequest: Encodable, Sendable, Equatable { public let mastodonProviderIds: [String]? public let crossPostToBluesky: Bool? public let crossPostToLinkedIn: Bool? + /// Cross-post fan-out to X/Twitter (G7). + /// + /// UNVERIFIED — the exact request field name could NOT be confirmed against + /// the live `POST /api/messages` (the test account has no linked X identity + /// and OpenAPI doesn't model the request body). `crossPostToTwitter` is + /// pattern-matched to the confirmed `crossPostToBluesky` / + /// `crossPostToLinkedIn` naming (high confidence). Confirm once an account + /// with a linked X identity is available; the OAuth provider slug is + /// `twitter`, verified live 2026-07-31 via `GET /api/auth/twitter/status`. + public let crossPostToTwitter: Bool? public init( content: String, @@ -252,7 +262,8 @@ public struct CreateMessageRequest: Encodable, Sendable, Equatable { scheduledAt: Date? = nil, mastodonProviderIds: [String]? = nil, crossPostToBluesky: Bool? = nil, - crossPostToLinkedIn: Bool? = nil + crossPostToLinkedIn: Bool? = nil, + crossPostToTwitter: Bool? = nil ) { self.content = content self.publiclyVisible = publiclyVisible @@ -265,12 +276,14 @@ public struct CreateMessageRequest: Encodable, Sendable, Equatable { self.mastodonProviderIds = mastodonProviderIds self.crossPostToBluesky = crossPostToBluesky self.crossPostToLinkedIn = crossPostToLinkedIn + self.crossPostToTwitter = crossPostToTwitter } private enum CodingKeys: String, CodingKey { case content, publiclyVisible, tags, parentId, pushedMessageId case imageUrls, videoUrls, scheduledAt case mastodonProviderIds, crossPostToBluesky, crossPostToLinkedIn + case crossPostToTwitter } public func encode(to encoder: Encoder) throws { @@ -288,6 +301,7 @@ public struct CreateMessageRequest: Encodable, Sendable, Equatable { try container.encodeIfPresent(mastodonProviderIds, forKey: .mastodonProviderIds) try container.encodeIfPresent(crossPostToBluesky, forKey: .crossPostToBluesky) try container.encodeIfPresent(crossPostToLinkedIn, forKey: .crossPostToLinkedIn) + try container.encodeIfPresent(crossPostToTwitter, forKey: .crossPostToTwitter) } } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ModerationDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ModerationDTO.swift new file mode 100644 index 0000000..ab32f77 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/ModerationDTO.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - Moderation DTOs (the-gaps.md G2) +// +// Block / mute / report. Envelope keys verified live 2026-07-31 (authenticated +// Bearer probe): `GET /api/user/blocks` → `{ blockedUsers: [...], pagination }`, +// `GET /api/user/mutes` → `{ mutedUsers: [...], pagination }`. The list rows +// were empty in the probe, so fields beyond `id` are modelled optional — they +// follow the standard compact-user shape `{ id, username, displayName, avatar }`. + +/// A user as surfaced by the block/mute list endpoints. +public struct ModeratedUserDTO: Decodable, Sendable, Equatable, Identifiable { + public let id: String + public let username: String? + public let displayName: String? + public let avatar: String? + + public init(id: String, username: String? = nil, displayName: String? = nil, avatar: String? = nil) { + self.id = id + self.username = username + self.displayName = displayName + self.avatar = avatar + } +} + +/// `GET /api/user/blocks` — `{ blockedUsers: [...], pagination: {...} }`. +public struct BlockedUsersResponse: Decodable, Sendable, Equatable { + public let blockedUsers: [ModeratedUserDTO] + public let pagination: PaginationInfo? + + public init(blockedUsers: [ModeratedUserDTO], pagination: PaginationInfo? = nil) { + self.blockedUsers = blockedUsers + self.pagination = pagination + } +} + +/// `GET /api/user/mutes` — `{ mutedUsers: [...], pagination: {...} }`. +public struct MutedUsersResponse: Decodable, Sendable, Equatable { + public let mutedUsers: [ModeratedUserDTO] + public let pagination: PaginationInfo? + + public init(mutedUsers: [ModeratedUserDTO], pagination: PaginationInfo? = nil) { + self.mutedUsers = mutedUsers + self.pagination = pagination + } +} + +/// Request body for `POST /api/users/{username}/report` and +/// `POST /api/messages/{id}/report`: `{ reason, detail? }`. `reason` is one of +/// `harassment|spam|misinformation|inappropriate|other` (validated in the +/// domain layer via the `ReportReason` enum). +public struct ReportRequest: Encodable, Sendable, Equatable { + public let reason: String + public let detail: String? + + public init(reason: String, detail: String? = nil) { + self.reason = reason + self.detail = detail + } +} + +/// A decode-safe acknowledgement for the moderation write actions. Every field +/// is optional so it decodes regardless of the exact success body the server +/// returns (`{}`, `{ ok: true }`, `{ blocked: true }`, …). The service sends +/// these via `sendVoid`, so the body is ignored; this type exists only to give +/// the builders a concrete `Request` return type. +public struct ModerationAck: Decodable, Sendable, Equatable { + public let ok: Bool? + + public init(ok: Bool? = nil) { + self.ok = ok + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OAuthDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OAuthDTO.swift index 0650828..0f90146 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OAuthDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/OAuthDTO.swift @@ -14,11 +14,18 @@ import Foundation /// - `mastodon` → 307 to `/oauth/authorize` (requires an `instance`) /// - `bluesky` → 307 to `bsky.social/oauth/authorize` (AT-proto PAR/DPoP) /// - `linkedin` → 307 to `linkedin.com/oauth/v2/authorization` +/// - `twitter` → 307 to X/Twitter's OAuth authorize page (G7). The provider +/// slug is `twitter` (not `x`): the live routes are +/// `/api/auth/twitter/{authorize,callback,status}`, and +/// `GET /api/auth/twitter/status` returns +/// `{ configured: true, redirectUri: ".../api/auth/twitter/callback" }` +/// (verified live 2026-07-31). public enum OAuthProvider: String, Sendable, Equatable, CaseIterable { case github case mastodon case bluesky case linkedin + case twitter } // MARK: - LinkedInStatusResponse @@ -44,6 +51,30 @@ public struct LinkedInStatusResponse: Decodable, Sendable, Equatable { } } +// MARK: - TwitterStatusResponse (G7) + +/// Response body for `GET /api/auth/twitter/status` (public, no auth): +/// `{ "configured": true, "redirectUri": "https://…/api/auth/twitter/callback" }`. +/// +/// Same shape as `LinkedInStatusResponse` (a `{ configured, redirectUri }` +/// pair) rather than the `configured`-only `ProviderStatusResponse` used by +/// Bluesky / Mastodon — verified live 2026-07-31, which returned +/// `{ configured: true, redirectUri: ".../api/auth/twitter/callback" }`. +/// `redirectUri` is the registered **web** callback URL (an +/// `https://interlinedlist.com/…` URL, not a native custom scheme). +public struct TwitterStatusResponse: Decodable, Sendable, Equatable { + /// Whether the server has an X/Twitter OAuth client configured. + public let configured: Bool + /// The registered OAuth redirect/callback URL (a web URL on the + /// `interlinedlist.com` domain). + public let redirectUri: String + + public init(configured: Bool, redirectUri: String) { + self.configured = configured + self.redirectUri = redirectUri + } +} + // MARK: - Provider status (NW-4) /// Shared response for `GET /api/auth/bluesky/status` and diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/PublicProfileDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/PublicProfileDTO.swift new file mode 100644 index 0000000..36d021f --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/PublicProfileDTO.swift @@ -0,0 +1,51 @@ +import Foundation + +// MARK: - PublicProfileDTO (the-gaps.md D2) +// +// `GET /api/users/{username}` — the public profile of any user by handle. +// Shape verified live 2026-07-31 (read-only). This endpoint did not exist when +// decision 0002 introduced the embedded-author fallback; it now does, and is +// strictly richer (bio, join date, private flag, follower/following counts). + +public struct PublicProfileDTO: Decodable, Sendable, Equatable, Identifiable { + public let id: String + public let username: String + public let displayName: String? + public let avatar: String? + public let headerImage: String? + public let bio: String? + public let joinedAt: Date? + public let isPrivate: Bool? + public let followerCount: Int? + public let followingCount: Int? + public let publicMessageCount: Int? + public let publicListCount: Int? + + public init( + id: String, + username: String, + displayName: String? = nil, + avatar: String? = nil, + headerImage: String? = nil, + bio: String? = nil, + joinedAt: Date? = nil, + isPrivate: Bool? = nil, + followerCount: Int? = nil, + followingCount: Int? = nil, + publicMessageCount: Int? = nil, + publicListCount: Int? = nil + ) { + self.id = id + self.username = username + self.displayName = displayName + self.avatar = avatar + self.headerImage = headerImage + self.bio = bio + self.joinedAt = joinedAt + self.isPrivate = isPrivate + self.followerCount = followerCount + self.followingCount = followingCount + self.publicMessageCount = publicMessageCount + self.publicListCount = publicListCount + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SearchDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SearchDTO.swift new file mode 100644 index 0000000..a5b6835 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SearchDTO.swift @@ -0,0 +1,47 @@ +import Foundation + +// MARK: - Search response envelopes +// +// The three search endpoints reuse the existing resource DTOs for their rows +// (`MessageDTO`, `ListDTO`, `DocumentDTO`) wrapped under a per-resource +// collection key. Shapes verified live 2026-07-31 (authenticated Bearer probe): +// +// GET /api/messages/search?q=… -> { "messages": [MessageDTO] } (POST → 405) +// GET /api/lists/search?q=… -> { "lists": [ListDTO], "pagination": {…} } +// GET /api/documents/search?q=… -> { "documents": [DocumentDTO] } +// +// `pagination` is modelled optional because messages/documents search omit the +// envelope today while lists search includes it. Decoding tolerates both. + +/// `GET /api/messages/search` response. +public struct MessageSearchResponse: Decodable, Sendable, Equatable { + public let messages: [MessageDTO] + public let pagination: PaginationInfo? + + public init(messages: [MessageDTO], pagination: PaginationInfo? = nil) { + self.messages = messages + self.pagination = pagination + } +} + +/// `GET /api/lists/search` response. +public struct ListSearchResponse: Decodable, Sendable, Equatable { + public let lists: [ListDTO] + public let pagination: PaginationInfo? + + public init(lists: [ListDTO], pagination: PaginationInfo? = nil) { + self.lists = lists + self.pagination = pagination + } +} + +/// `GET /api/documents/search` response. +public struct DocumentSearchResponse: Decodable, Sendable, Equatable { + public let documents: [DocumentDTO] + public let pagination: PaginationInfo? + + public init(documents: [DocumentDTO], pagination: PaginationInfo? = nil) { + self.documents = documents + self.pagination = pagination + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SharingDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SharingDTO.swift new file mode 100644 index 0000000..4afcbdd --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/SharingDTO.swift @@ -0,0 +1,153 @@ +import Foundation + +// MARK: - Sharing DTOs (the-gaps.md G3) +// +// Tokenized share links for lists and documents. Shapes verified live +// 2026-07-31 (authorized create→capture→revoke on the test account's own list): +// +// POST /api/lists/{id}/share-links {role, expiresAt?} +// -> { token, url, role, expiresAt } (201) +// GET /api/lists/{id}/share-links +// -> { shareLinks: [{ token, role, expiresAt, createdAt, revokedAt, url }] } +// GET /api/lists/shared/{token} +// -> { role, canClaim, needsAuth, list: { id, title, description, isPublic, updatedAt } } +// DELETE /api/lists/{id}/share-links/{token} -> { revoked: true } +// POST /api/lists/shared/{token} -> { listId, role } (claim) +// +// The document endpoints mirror this with `document` in place of `list`. + +/// A share link row (create response + list rows share this shape; `createdAt` +/// / `revokedAt` are present only on the list rows). +public struct ShareLinkDTO: Decodable, Sendable, Equatable { + public let token: String + public let url: String? + public let role: String + public let expiresAt: Date? + public let createdAt: Date? + public let revokedAt: Date? + + public init( + token: String, + url: String? = nil, + role: String, + expiresAt: Date? = nil, + createdAt: Date? = nil, + revokedAt: Date? = nil + ) { + self.token = token + self.url = url + self.role = role + self.expiresAt = expiresAt + self.createdAt = createdAt + self.revokedAt = revokedAt + } +} + +/// `GET /api/{lists,documents}/{id}/share-links` — `{ shareLinks: [...] }`. +public struct ShareLinksResponse: Decodable, Sendable, Equatable { + public let shareLinks: [ShareLinkDTO] + public init(shareLinks: [ShareLinkDTO]) { self.shareLinks = shareLinks } +} + +/// Body for `POST /api/{lists,documents}/{id}/share-links`. `role` is one of +/// `watcher|collaborator|manager`; `expiresAt` omitted/nil means a permanent link. +public struct CreateShareLinkRequest: Encodable, Sendable, Equatable { + public let role: String + public let expiresAt: Date? + public init(role: String, expiresAt: Date? = nil) { + self.role = role + self.expiresAt = expiresAt + } +} + +/// `DELETE …/share-links/{token}` — `{ revoked: true }`. +public struct RevokeShareResponse: Decodable, Sendable, Equatable { + public let revoked: Bool? + public init(revoked: Bool? = nil) { self.revoked = revoked } +} + +// MARK: - Resolve + +/// The list embedded in a resolved list-share. +public struct SharedListInfoDTO: Decodable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let description: String? + public let isPublic: Bool? + public let updatedAt: Date? + + public init(id: String, title: String, description: String? = nil, isPublic: Bool? = nil, updatedAt: Date? = nil) { + self.id = id + self.title = title + self.description = description + self.isPublic = isPublic + self.updatedAt = updatedAt + } +} + +/// `GET /api/lists/shared/{token}` — resolve a list share link. +public struct ResolvedListShareDTO: Decodable, Sendable, Equatable { + public let role: String + public let canClaim: Bool? + public let needsAuth: Bool? + public let list: SharedListInfoDTO? + + public init(role: String, canClaim: Bool? = nil, needsAuth: Bool? = nil, list: SharedListInfoDTO? = nil) { + self.role = role + self.canClaim = canClaim + self.needsAuth = needsAuth + self.list = list + } +} + +/// The document embedded in a resolved document-share. +public struct SharedDocumentInfoDTO: Decodable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let isPublic: Bool? + public let updatedAt: Date? + + public init(id: String, title: String, isPublic: Bool? = nil, updatedAt: Date? = nil) { + self.id = id + self.title = title + self.isPublic = isPublic + self.updatedAt = updatedAt + } +} + +/// `GET /api/documents/shared/{token}` — resolve a document share link. +public struct ResolvedDocumentShareDTO: Decodable, Sendable, Equatable { + public let role: String + public let canClaim: Bool? + public let needsAuth: Bool? + public let document: SharedDocumentInfoDTO? + + public init(role: String, canClaim: Bool? = nil, needsAuth: Bool? = nil, document: SharedDocumentInfoDTO? = nil) { + self.role = role + self.canClaim = canClaim + self.needsAuth = needsAuth + self.document = document + } +} + +// MARK: - Claim + +/// `POST /api/lists/shared/{token}` — claim a list share link. +public struct ClaimListShareResponse: Decodable, Sendable, Equatable { + public let listId: String? + public let role: String? + public init(listId: String? = nil, role: String? = nil) { + self.listId = listId + self.role = role + } +} + +/// `POST /api/documents/shared/{token}` — claim a document share link. +public struct ClaimDocumentShareResponse: Decodable, Sendable, Equatable { + public let documentId: String? + public let role: String? + public init(documentId: String? = nil, role: String? = nil) { + self.documentId = documentId + self.role = role + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift index 06ed1af..00a438e 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift @@ -122,6 +122,16 @@ public enum Auth { Request(method: .get, path: "/api/auth/linkedin/status", auth: .none) } + /// `GET /api/auth/twitter/status` — report whether X/Twitter OAuth is + /// configured and the registered redirect URI (G7). **Public** (`.none`), + /// mirroring `linkedinStatus()`: verified live 2026-07-31, the endpoint + /// returned `200` with + /// `{ "configured": true, "redirectUri": "https://…/api/auth/twitter/callback" }` + /// to an unauthenticated caller. The provider slug is `twitter` (not `x`). + public static func twitterStatus() -> Request { + Request(method: .get, path: "/api/auth/twitter/status", auth: .none) + } + /// `GET /api/auth/bluesky/status` — whether Bluesky OAuth is configured /// on the server. Bearer-authenticated (NW-4). public static func blueskyStatus() -> Request { diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DirectMessagesEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DirectMessagesEndpoint.swift new file mode 100644 index 0000000..82f7d96 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DirectMessagesEndpoint.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Request builders for the **Direct Messages** API group (the-gaps.md G1) — +/// private 1:1 conversations between mutual followers. Free tier (no +/// subscription required), including image attachments. +/// +/// Paths + shapes verified live 2026-07-31 (authorized recon DM, then trashed). +/// Follows the `Request.swift` conventions: factories returning `Request`, +/// explicit `.bearer` auth, path-only URLs, nil-skipping query items. +public enum DirectMessages { + + /// `GET /api/dm?folder=inbox|sent|deleted&cursor=…` — one folder listing, + /// cursor-paginated. `folder` defaults to the inbox. + public static func folder(_ folder: String = "inbox", cursor: String? = nil) -> Request { + Request( + method: .get, + path: "/api/dm", + query: [.string("folder", folder), .string("cursor", cursor)], + auth: .bearer + ) + } + + /// `POST /api/dm` — send a direct message. Returns the created message + /// wrapped under `message` (201). + public static func send(_ body: SendDirectMessageRequest) -> Request { + Request(method: .post, path: "/api/dm", body: .json(body), auth: .bearer) + } + + /// `GET /api/dm/thread/{username}` — the conversation with `username` + /// (chronological). Opening a thread marks received-unread messages read. + public static func thread(username: String, cursor: String? = nil) -> Request { + Request( + method: .get, + path: "/api/dm/thread/\(username)", + query: [.string("cursor", cursor)], + auth: .bearer + ) + } + + /// `GET /api/dm/thread/{username}/updates` — lightweight incremental fetch + /// for near-real-time polling. Same envelope as `thread`. + public static func threadUpdates(username: String, since: String? = nil) -> Request { + Request( + method: .get, + path: "/api/dm/thread/\(username)/updates", + query: [.string("since", since)], + auth: .bearer + ) + } + + /// `GET /api/dm/recipients` — the users the current account may DM (mutual + /// followers, not blocked). + public static func recipients() -> Request { + Request(method: .get, path: "/api/dm/recipients", auth: .bearer) + } + + /// `GET /api/dm/unread-count` — total unread received DMs across all threads. + public static func unreadCount() -> Request { + Request(method: .get, path: "/api/dm/unread-count", auth: .bearer) + } + + /// `POST /api/dm/{id}/read` — mark a received message read (recipient-scoped). + public static func markRead(id: String) -> Request { + Request(method: .post, path: "/api/dm/\(id)/read", auth: .bearer) + } + + /// `POST /api/dm/{id}/trash` — soft-delete the caller's own side. + public static func trash(id: String) -> Request { + Request(method: .post, path: "/api/dm/\(id)/trash", auth: .bearer) + } + + /// `POST /api/dm/{id}/restore` — undo the caller's own soft-delete. + public static func restore(id: String) -> Request { + Request(method: .post, path: "/api/dm/\(id)/restore", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentTemplatesEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentTemplatesEndpoint.swift new file mode 100644 index 0000000..31e06e2 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/DocumentTemplatesEndpoint.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Server document-template builders (the-gaps.md G12), added to the existing +/// `Documents` namespace as an additive extension so the base endpoint file is +/// untouched. Verified live 2026-07-31. +public extension Documents { + + /// `GET /api/documents/templates` — the user's saved template documents + /// (ensures the `_templates` folder exists). + static func templates() -> Request { + Request(method: .get, path: "/api/documents/templates", auth: .bearer) + } + + /// `POST /api/documents/from-template` — create a new document by copying + /// the given template. Returns 201 with an empty body; callers reload the + /// documents list to surface the new document. + static func createFromTemplate(_ body: CreateFromTemplateRequest) -> Request { + Request(method: .post, path: "/api/documents/from-template", body: .json(body), auth: .bearer) + } + + /// `POST /api/documents/templates/seed-defaults` — seed the default + /// starter templates into the `_templates` folder. + static func seedDefaultTemplates() -> Request { + Request(method: .post, path: "/api/documents/templates/seed-defaults", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/LinkedInEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/LinkedInEndpoint.swift new file mode 100644 index 0000000..0310258 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/LinkedInEndpoint.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Request builders for the **LinkedIn posting targets** API group (the-gaps.md +/// G11a) — the destinations the user can cross-post to on LinkedIn (their +/// personal profile and, when the org scope is granted, org pages). +/// +/// Paths + shapes verified live 2026-07-31 (read-only). The org-page half +/// (`/api/orgs/{id}/linkedin-page`) is **not deployed** live (G11b, deferred). +public enum LinkedIn { + + /// `GET /api/linkedin/posting-targets` — targets with their enabled state, + /// plus `orgScopeMissing` when org pages are unavailable. + public static func postingTargets() -> Request { + Request(method: .get, path: "/api/linkedin/posting-targets", auth: .bearer) + } + + /// `GET /api/linkedin/targets` — the available targets (no enabled state). + public static func targets() -> Request { + Request(method: .get, path: "/api/linkedin/targets", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListFoldersEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListFoldersEndpoint.swift new file mode 100644 index 0000000..e315809 --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListFoldersEndpoint.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Request builders for the **List Folders** API group (the-gaps.md G6) — +/// hierarchical folders that organize the current user's lists. Distinct from +/// document folders (`/api/documents/folders/*`); these live at `/api/folders`. +/// +/// Paths verified live 2026-07-31 (`GET /api/folders` → 200 `{folders:[]}`). +/// Creating a folder is subscriber-gated server-side; the domain service also +/// checks entitlements before calling. +/// +/// Follows the `Request.swift` conventions: factories returning `Request`, +/// explicit `.bearer` auth, path-only URLs, `RequestBody.json` for bodies. +public enum ListFolders { + + /// `GET /api/folders` — all non-deleted list folders (flat array). + public static func list() -> Request { + Request(method: .get, path: "/api/folders", auth: .bearer) + } + + /// `POST /api/folders` — create a folder (subscriber only). + public static func create(_ body: CreateListFolderRequest) -> Request { + Request(method: .post, path: "/api/folders", body: .json(body), auth: .bearer) + } + + /// `PUT /api/folders/{id}` — rename and/or move a folder. + public static func update(id: String, _ body: UpdateListFolderRequest) -> Request { + Request(method: .put, path: "/api/folders/\(id)", body: .json(body), auth: .bearer) + } + + /// `DELETE /api/folders/{id}` — soft-delete a folder (its lists detach to + /// root; child folders cascade). + public static func delete(id: String) -> Request { + Request(method: .delete, path: "/api/folders/\(id)", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ModerationEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ModerationEndpoint.swift new file mode 100644 index 0000000..1f9964a --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ModerationEndpoint.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Request builders for the **Moderation** API group (the-gaps.md G2) — block, +/// mute, and report users and messages. +/// +/// Paths verified against the live `/help/api/moderation` docs and the +/// 2026-07-31 probe (`GET /api/user/blocks` / `GET /api/user/mutes` return 200). +/// The block/mute/report *write* actions are fire-and-forget from the client's +/// point of view — the domain service sends them via `sendVoid`, so their +/// response body is never decoded (hence the tolerant `ModerationAck` return). +/// +/// Auth: all `.bearer` (the docs note session-or-Bearer; Bearer is verified for +/// the list reads and is the app's default transport). +public enum Moderation { + + // MARK: - Lists + + /// `GET /api/user/blocks` — the users the current account blocks. + public static func blocks(limit: Int? = nil, offset: Int? = nil) -> Request { + Request( + method: .get, + path: "/api/user/blocks", + query: [.int("limit", limit), .int("offset", offset)], + auth: .bearer + ) + } + + /// `GET /api/user/mutes` — the users the current account mutes. + public static func mutes(limit: Int? = nil, offset: Int? = nil) -> Request { + Request( + method: .get, + path: "/api/user/mutes", + query: [.int("limit", limit), .int("offset", offset)], + auth: .bearer + ) + } + + // MARK: - Block / unblock + + /// `POST /api/users/{username}/block` — block a user (mutual invisibility + /// in feeds, search, threads, and DM eligibility). + public static func block(username: String) -> Request { + Request(method: .post, path: "/api/users/\(username)/block", auth: .bearer) + } + + /// `DELETE /api/users/{username}/block` — unblock a user. + public static func unblock(username: String) -> Request { + Request(method: .delete, path: "/api/users/\(username)/block", auth: .bearer) + } + + // MARK: - Mute / unmute + + /// `POST /api/users/{username}/mute` — mute a user (hide their content + /// without the mutual invisibility of a block). + public static func mute(username: String) -> Request { + Request(method: .post, path: "/api/users/\(username)/mute", auth: .bearer) + } + + /// `DELETE /api/users/{username}/mute` — unmute a user. + public static func unmute(username: String) -> Request { + Request(method: .delete, path: "/api/users/\(username)/mute", auth: .bearer) + } + + // MARK: - Report + + /// `POST /api/users/{username}/report` — report a user with a reason and + /// optional free-text detail. + public static func reportUser( + username: String, + reason: String, + detail: String? = nil + ) -> Request { + Request( + method: .post, + path: "/api/users/\(username)/report", + body: .json(ReportRequest(reason: reason, detail: detail)), + auth: .bearer + ) + } + + /// `POST /api/messages/{id}/report` — report a message with a reason and + /// optional free-text detail. + public static func reportMessage( + id: String, + reason: String, + detail: String? = nil + ) -> Request { + Request( + method: .post, + path: "/api/messages/\(id)/report", + body: .json(ReportRequest(reason: reason, detail: detail)), + auth: .bearer + ) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SearchEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SearchEndpoint.swift new file mode 100644 index 0000000..01ac2bc --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SearchEndpoint.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Request builders for the **Search** endpoints (the-gaps.md G5) — full-text +/// search across the current user's messages, lists, and documents. +/// +/// Each route is a sub-route of its resource, but the three are grouped in one +/// namespace because they share a shape (a `q` term plus optional pagination) +/// and one App surface — the global search field — fans out to all of them. +/// +/// Paths + verbs verified live 2026-07-31 (authenticated Bearer probe): +/// - `GET /api/messages/search?q=…` — the live verb is **GET**; a `POST` to +/// this path returns `405 Method Not Allowed`. +/// - `GET /api/lists/search?q=…` +/// - `GET /api/documents/search?q=…` +/// +/// Follows the `Request.swift` conventions: factories returning `Request`, +/// explicit `.bearer` auth, path-only URLs, nil-skipping query items, never +/// throwing. +public enum Search { + + /// `GET /api/messages/search?q=` — full-text search over the + /// current user's messages. `limit`/`offset` are accepted for forward + /// compatibility (the live route ignores absent values). + public static func messages( + query: String, + limit: Int? = nil, + offset: Int? = nil + ) -> Request { + Request( + method: .get, + path: "/api/messages/search", + query: [ + .string("q", query), + .int("limit", limit), + .int("offset", offset) + ], + auth: .bearer + ) + } + + /// `GET /api/lists/search?q=` — search the current user's lists by + /// title or description. + public static func lists( + query: String, + limit: Int? = nil, + offset: Int? = nil + ) -> Request { + Request( + method: .get, + path: "/api/lists/search", + query: [ + .string("q", query), + .int("limit", limit), + .int("offset", offset) + ], + auth: .bearer + ) + } + + /// `GET /api/documents/search?q=` — search the current user's + /// documents by title or content. + public static func documents( + query: String, + limit: Int? = nil, + offset: Int? = nil + ) -> Request { + Request( + method: .get, + path: "/api/documents/search", + query: [ + .string("q", query), + .int("limit", limit), + .int("offset", offset) + ], + auth: .bearer + ) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SharingEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SharingEndpoint.swift new file mode 100644 index 0000000..2b2e97f --- /dev/null +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/SharingEndpoint.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Request builders for the **Sharing / Share Links** API group (the-gaps.md +/// G3) — tokenized view/edit/admin links for lists and documents. Creating a +/// link is subscriber-gated server-side (the domain service checks too); +/// resolving/claiming/revoking are not. +/// +/// Paths + shapes verified live 2026-07-31 (authorized create→capture→revoke). +/// Follows the `Request.swift` conventions. +public enum Sharing { + + // MARK: - Lists + + /// `GET /api/lists/{id}/share-links` — active links (owner only). + public static func listShareLinks(listId: String) -> Request { + Request(method: .get, path: "/api/lists/\(listId)/share-links", auth: .bearer) + } + + /// `POST /api/lists/{id}/share-links` — create a link (owner + subscriber). + public static func createListShareLink(listId: String, _ body: CreateShareLinkRequest) -> Request { + Request(method: .post, path: "/api/lists/\(listId)/share-links", body: .json(body), auth: .bearer) + } + + /// `DELETE /api/lists/{id}/share-links/{token}` — revoke a link. + public static func revokeListShareLink(listId: String, token: String) -> Request { + Request(method: .delete, path: "/api/lists/\(listId)/share-links/\(token)", auth: .bearer) + } + + /// `GET /api/lists/shared/{token}` — resolve a list share link. + public static func resolveListShare(token: String) -> Request { + Request(method: .get, path: "/api/lists/shared/\(token)", auth: .bearer) + } + + /// `POST /api/lists/shared/{token}` — claim an edit/admin list link. + public static func claimListShare(token: String) -> Request { + Request(method: .post, path: "/api/lists/shared/\(token)", auth: .bearer) + } + + // MARK: - Documents + + /// `GET /api/documents/{id}/share-links` — active links (owner only). + public static func documentShareLinks(documentId: String) -> Request { + Request(method: .get, path: "/api/documents/\(documentId)/share-links", auth: .bearer) + } + + /// `POST /api/documents/{id}/share-links` — create a link (owner + subscriber). + public static func createDocumentShareLink(documentId: String, _ body: CreateShareLinkRequest) -> Request { + Request(method: .post, path: "/api/documents/\(documentId)/share-links", body: .json(body), auth: .bearer) + } + + /// `DELETE /api/documents/{id}/share-links/{token}` — revoke a link. + public static func revokeDocumentShareLink(documentId: String, token: String) -> Request { + Request(method: .delete, path: "/api/documents/\(documentId)/share-links/\(token)", auth: .bearer) + } + + /// `GET /api/documents/shared/{token}` — resolve a document share link. + public static func resolveDocumentShare(token: String) -> Request { + Request(method: .get, path: "/api/documents/shared/\(token)", auth: .bearer) + } + + /// `POST /api/documents/shared/{token}` — claim an edit/admin document link. + public static func claimDocumentShare(token: String) -> Request { + Request(method: .post, path: "/api/documents/shared/\(token)", auth: .bearer) + } +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift index f27f836..12d8666 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/UserEndpoint.swift @@ -100,4 +100,11 @@ public enum User { auth: .bearer ) } + + /// `GET /api/users/{username}` — a public user profile by handle + /// (the-gaps.md D2). Bearer-authenticated so private/follow-aware + /// visibility resolves for the signed-in viewer; 404 for unknown handles. + public static func publicProfile(username: String) -> Request { + Request(method: .get, path: "/api/users/\(username)", auth: .bearer) + } } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/AuthOAuthEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/AuthOAuthEndpointTests.swift index 5ff0693..aa636db 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/AuthOAuthEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/AuthOAuthEndpointTests.swift @@ -127,4 +127,52 @@ final class AuthOAuthEndpointTests: XCTestCase { try JSONCoders.makeDecoder().decode(LinkedInStatusResponse.self, from: Data(json.utf8)) ) } + + // MARK: - twitterStatus builder (G7) + + func test_givenTwitterProvider_whenAuthorizeBuilt_thenTargetsProviderPath() { + // The X/Twitter OAuth provider slug is `twitter` (verified live + // 2026-07-31); the authorize route mirrors the other providers. + let request = Auth.authorize(provider: .twitter) + XCTAssertEqual(request.path, "/api/auth/twitter/authorize") + XCTAssertEqual(request.auth, .none) + } + + func test_givenTwitterStatus_whenBuilt_thenTargetsStatusPathNoAuth() { + // Mirrors `linkedinStatus()` — public 200 for anonymous callers. + let request = Auth.twitterStatus() + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.path, "/api/auth/twitter/status") + XCTAssertEqual(request.auth, .none) + XCTAssertNil(request.body) + XCTAssertTrue(request.query.isEmpty) + } + + // MARK: - TwitterStatusResponse decode (G7) + + func test_givenConfiguredTwitterStatusJSON_whenDecoded_thenMapsConfiguredAndRedirect() throws { + // Live fixture shape from the unauthenticated probe (2026-07-31): + // { configured: true, redirectUri: ".../api/auth/twitter/callback" }. + let json = #"{"configured":true,"redirectUri":"https://interlinedlist.com/api/auth/twitter/callback"}"# + let decoded = try JSONCoders.makeDecoder().decode(TwitterStatusResponse.self, from: Data(json.utf8)) + XCTAssertTrue(decoded.configured) + XCTAssertEqual(decoded.redirectUri, "https://interlinedlist.com/api/auth/twitter/callback") + } + + func test_givenUnconfiguredTwitterStatusJSON_whenDecoded_thenConfiguredIsFalse() throws { + // Boundary: a deployment where X/Twitter OAuth is not configured. + let json = #"{"configured":false,"redirectUri":""}"# + let decoded = try JSONCoders.makeDecoder().decode(TwitterStatusResponse.self, from: Data(json.utf8)) + XCTAssertFalse(decoded.configured) + XCTAssertEqual(decoded.redirectUri, "") + } + + func test_givenMissingConfiguredFieldTwitter_whenDecoded_thenThrows() throws { + // Invalid input: a partial body must fail to decode rather than + // silently defaulting (mirrors the LinkedInStatusResponse test). + let json = #"{"redirectUri":"https://interlinedlist.com/api/auth/twitter/callback"}"# + XCTAssertThrowsError( + try JSONCoders.makeDecoder().decode(TwitterStatusResponse.self, from: Data(json.utf8)) + ) + } } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/DirectMessagesEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/DirectMessagesEndpointTests.swift new file mode 100644 index 0000000..a5ec8ce --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/DirectMessagesEndpointTests.swift @@ -0,0 +1,138 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the Direct Messages endpoint group (the-gaps.md G1). +/// Fixtures mirror the shapes captured live 2026-07-31. +final class DirectMessagesEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + /// A DM row exactly as the live API returns it (fractional-second date, + /// embedded sender/recipient). + private func dmJSON(id: String) -> String { + #""" + {"id":"\#(id)","pairKey":"s:r","senderId":"s","recipientId":"r", + "body":"hi there","imageUrls":[], + "createdAt":"2026-07-31T22:20:32.337Z","readAt":null, + "sender":{"id":"s","username":"messenger","displayName":"Messenger","avatar":"https://cdn/s.png"}, + "recipient":{"id":"r","username":"adron","displayName":"Adron","avatar":"https://cdn/r.png"}, + "preview":"hi there"} + """# + } + + // MARK: - Builder shape assertions + + func test_givenDMBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(DirectMessages.folder("sent").path, "/api/dm") + XCTAssertEqual(DirectMessages.folder("sent").query.first(where: { $0.name == "folder" })?.value, "sent") + XCTAssertEqual(DirectMessages.send(SendDirectMessageRequest(recipientId: "r", body: "hi")).method, .post) + XCTAssertEqual(DirectMessages.thread(username: "adron").path, "/api/dm/thread/adron") + XCTAssertEqual(DirectMessages.threadUpdates(username: "adron").path, "/api/dm/thread/adron/updates") + XCTAssertEqual(DirectMessages.recipients().path, "/api/dm/recipients") + XCTAssertEqual(DirectMessages.unreadCount().path, "/api/dm/unread-count") + XCTAssertEqual(DirectMessages.markRead(id: "m1").path, "/api/dm/m1/read") + XCTAssertEqual(DirectMessages.trash(id: "m1").path, "/api/dm/m1/trash") + XCTAssertEqual(DirectMessages.trash(id: "m1").method, .post) + XCTAssertEqual(DirectMessages.restore(id: "m1").path, "/api/dm/m1/restore") + } + + // MARK: - Happy path + + func test_givenFolderBody_whenSent_thenDecodesItemsAndCursor() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"items":[\#(dmJSON(id: "m1"))],"nextCursor":"c2"}"#)) + + let page = try await client.send(DirectMessages.folder("inbox")) + + XCTAssertEqual(page.items.map(\.id), ["m1"]) + XCTAssertEqual(page.items.first?.sender?.username, "messenger") + XCTAssertEqual(page.nextCursor, "c2") + } + + func test_givenCreateBody_whenSendSent_thenDecodesWrappedMessageAndEncodesBody() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"message":\#(dmJSON(id: "m9"))}"#)) + + let response = try await client.send( + DirectMessages.send(SendDirectMessageRequest(recipientId: "r", body: "hi there")) + ) + + XCTAssertEqual(response.message.id, "m9") + let received = await transport.received + let body = try XCTUnwrap(received[0].httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["recipientId"] as? String, "r") + XCTAssertEqual(json?["body"] as? String, "hi there") + } + + func test_givenThreadBody_whenSent_thenDecodesItemsAndMetadata() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"items":[\#(dmJSON(id: "m1"))],"olderCursor":null,"isMutual":true,"isBlocked":false, + "otherUser":{"id":"r","username":"adron","displayName":"Adron","avatar":"https://cdn/r.png"}} + """#)) + + let thread = try await client.send(DirectMessages.thread(username: "adron")) + + XCTAssertEqual(thread.items.map(\.id), ["m1"]) + XCTAssertEqual(thread.isMutual, true) + XCTAssertEqual(thread.otherUser?.username, "adron") + } + + func test_givenRecipientsBody_whenSent_thenDecodesUsers() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"recipients":[{"id":"r","username":"adron","displayName":"Adron"}]}"#)) + + let response = try await client.send(DirectMessages.recipients()) + + XCTAssertEqual(response.recipients.map(\.id), ["r"]) + } + + func test_givenUnreadCountBody_whenSent_thenDecodesCount() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"count":3}"#)) + + let response = try await client.send(DirectMessages.unreadCount()) + + XCTAssertEqual(response.count, 3) + } + + // MARK: - API failure + + func test_givenForbidden_whenSendSent_thenThrowsForbidden() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"not mutual followers"}"#, status: 403)) + + do { + _ = try await client.send(DirectMessages.send(SendDirectMessageRequest(recipientId: "r", body: "hi"))) + XCTFail("Expected forbidden") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "not mutual followers")) + } + } + + // MARK: - Empty / boundary + + func test_givenEmptyFolder_whenSent_thenReturnsNoItems() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"items":[],"nextCursor":null}"#)) + + let page = try await client.send(DirectMessages.folder("deleted")) + + XCTAssertTrue(page.items.isEmpty) + XCTAssertNil(page.nextCursor) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentTemplatesEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentTemplatesEndpointTests.swift new file mode 100644 index 0000000..d87e629 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/DocumentTemplatesEndpointTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the server document-templates builders (the-gaps.md G12). +final class DocumentTemplatesEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + func test_givenTemplateBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(Documents.templates().path, "/api/documents/templates") + XCTAssertEqual(Documents.templates().method, .get) + XCTAssertEqual(Documents.templates().auth, .bearer) + + XCTAssertEqual(Documents.createFromTemplate(CreateFromTemplateRequest(templateDocumentId: "t1")).method, .post) + XCTAssertEqual(Documents.createFromTemplate(CreateFromTemplateRequest(templateDocumentId: "t1")).path, "/api/documents/from-template") + + XCTAssertEqual(Documents.seedDefaultTemplates().path, "/api/documents/templates/seed-defaults") + XCTAssertEqual(Documents.seedDefaultTemplates().method, .post) + } + + func test_givenTemplatesBody_whenSent_thenDecodesTemplates() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"folderCreated":false,"templatesFolderId":"f-tpl", + "templates":[ + {"id":"t1","title":"Recipe","relativePath":"recipe.md"}, + {"id":"t2","title":"Social Media Campaign","relativePath":"social-media-campaign.md"} + ]} + """#)) + + let response = try await client.send(Documents.templates()) + + XCTAssertEqual(response.templates.map(\.id), ["t1", "t2"]) + XCTAssertEqual(response.templatesFolderId, "f-tpl") + } + + func test_givenTemplateId_whenCreateFromTemplateSent_thenEncodesTemplateDocumentId() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{}"#, status: 201)) + + _ = try await client.send(Documents.createFromTemplate(CreateFromTemplateRequest(templateDocumentId: "t1"))) + + let received = await transport.received + let body = try XCTUnwrap(received[0].httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["templateDocumentId"] as? String, "t1") + } + + func test_givenBadRequest_whenCreateFromTemplateSent_thenThrows() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"templateDocumentId is required."}"#, status: 400)) + + do { + _ = try await client.send(Documents.createFromTemplate(CreateFromTemplateRequest(templateDocumentId: ""))) + XCTFail("Expected badRequest") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "templateDocumentId is required.")) + } + } + + func test_givenNoTemplates_whenSent_thenReturnsEmpty() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"folderCreated":true,"templatesFolderId":"f","templates":[]}"#)) + + let response = try await client.send(Documents.templates()) + + XCTAssertTrue(response.templates.isEmpty) + XCTAssertEqual(response.folderCreated, true) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/LinkedInEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/LinkedInEndpointTests.swift new file mode 100644 index 0000000..ab96a64 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/LinkedInEndpointTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the LinkedIn posting-targets endpoint group (the-gaps.md G11a). +final class LinkedInEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + func test_givenLinkedInBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(LinkedIn.postingTargets().path, "/api/linkedin/posting-targets") + XCTAssertEqual(LinkedIn.postingTargets().method, .get) + XCTAssertEqual(LinkedIn.postingTargets().auth, .bearer) + XCTAssertEqual(LinkedIn.targets().path, "/api/linkedin/targets") + } + + func test_givenPostingTargetsBody_whenSent_thenDecodesTargetsAndOrgScopeFlag() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"targets":[{"kind":"personal","label":"Adron Hall","avatarUrl":"https://cdn/a.png","enabled":true}], + "orgScopeMissing":true} + """#)) + + let response = try await client.send(LinkedIn.postingTargets()) + + XCTAssertEqual(response.targets.map(\.kind), ["personal"]) + XCTAssertEqual(response.targets.first?.enabled, true) + XCTAssertEqual(response.orgScopeMissing, true) + } + + func test_givenServerError_whenSent_thenThrowsHttpStatus() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"boom"}"#, status: 500)) + + do { + _ = try await client.send(LinkedIn.postingTargets()) + XCTFail("Expected httpStatus") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + func test_givenNoTargets_whenSent_thenReturnsEmpty() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"targets":[]}"#)) + + let response = try await client.send(LinkedIn.targets()) + + XCTAssertTrue(response.targets.isEmpty) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ListFoldersEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListFoldersEndpointTests.swift new file mode 100644 index 0000000..de9234b --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ListFoldersEndpointTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the List Folders endpoint group (the-gaps.md G6). +final class ListFoldersEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + // MARK: - Builder shape assertions + + func test_givenListFolderBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(ListFolders.list().path, "/api/folders") + XCTAssertEqual(ListFolders.list().method, .get) + XCTAssertEqual(ListFolders.list().auth, .bearer) + + XCTAssertEqual(ListFolders.create(CreateListFolderRequest(name: "A")).method, .post) + XCTAssertEqual(ListFolders.create(CreateListFolderRequest(name: "A")).path, "/api/folders") + + XCTAssertEqual(ListFolders.update(id: "f1", UpdateListFolderRequest(name: "B")).method, .put) + XCTAssertEqual(ListFolders.update(id: "f1", UpdateListFolderRequest(name: "B")).path, "/api/folders/f1") + + XCTAssertEqual(ListFolders.delete(id: "f1").method, .delete) + XCTAssertEqual(ListFolders.delete(id: "f1").path, "/api/folders/f1") + } + + // MARK: - Happy path + + func test_givenFoldersBody_whenListSent_thenDecodesFolders() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"folders":[ + {"id":"f1","name":"Root","parentId":null}, + {"id":"f2","name":"Child","parentId":"f1"} + ]} + """#)) + + let response = try await client.send(ListFolders.list()) + + XCTAssertEqual(response.folders.map(\.id), ["f1", "f2"]) + XCTAssertEqual(response.folders.last?.parentId, "f1") + } + + func test_givenCreateBody_whenCreateSent_thenEncodesNameAndDecodesFolder() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"id":"f9","name":"New Folder","parentId":null}"#)) + + let folder = try await client.send(ListFolders.create(CreateListFolderRequest(name: "New Folder"))) + + XCTAssertEqual(folder.id, "f9") + let received = await transport.received + let body = try XCTUnwrap(received[0].httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["name"] as? String, "New Folder") + } + + // MARK: - API failure + + func test_givenForbidden_whenCreateSent_thenThrowsForbidden() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"subscriber required"}"#, status: 403)) + + do { + _ = try await client.send(ListFolders.create(CreateListFolderRequest(name: "A"))) + XCTFail("Expected forbidden") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "subscriber required")) + } + } + + // MARK: - Empty / boundary + + func test_givenNoFolders_whenListSent_thenReturnsEmpty() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"folders":[]}"#)) + + let response = try await client.send(ListFolders.list()) + + XCTAssertTrue(response.folders.isEmpty) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift index 32ffdae..daa82f6 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/MessagesEndpointTests.swift @@ -285,9 +285,27 @@ final class MessagesEndpointTests: XCTestCase { XCTAssertEqual(body["content"] as? String, "hi") XCTAssertNil(body["scheduledAt"]) XCTAssertNil(body["crossPostToBluesky"]) + XCTAssertNil(body["crossPostToTwitter"]) // G7 — omitted when unset XCTAssertNil(body["tags"]) } + func test_givenTwitterCrossPost_whenCreateBuilt_thenEncodesTwitterFlag() throws { + // Happy path (G7): `crossPostToTwitter: true` serializes on the wire + // exactly like `crossPostToBluesky` / `crossPostToLinkedIn`. The field + // name is UNVERIFIED (pattern-matched); this pins the encoding contract. + let request = Messages.create(CreateMessageRequest( + content: "hello X", + crossPostToTwitter: true + )) + + let body = try encodedBody(request) + XCTAssertEqual(body["content"] as? String, "hello X") + XCTAssertEqual(body["crossPostToTwitter"] as? Bool, true) + // Boundary: the other cross-post flags stay omitted when unset. + XCTAssertNil(body["crossPostToBluesky"]) + XCTAssertNil(body["crossPostToLinkedIn"]) + } + func test_givenCrossPostAndScheduled_whenCreateBuilt_thenEncodesAllSetFields() throws { // Happy path: the full M6 field set serializes correctly. let when = Date(timeIntervalSince1970: 1_800_000_000) @@ -300,7 +318,8 @@ final class MessagesEndpointTests: XCTestCase { scheduledAt: when, mastodonProviderIds: ["prov1", "prov2"], crossPostToBluesky: true, - crossPostToLinkedIn: false + crossPostToLinkedIn: false, + crossPostToTwitter: true )) let body = try encodedBody(request) @@ -310,6 +329,7 @@ final class MessagesEndpointTests: XCTestCase { XCTAssertEqual(body["mastodonProviderIds"] as? [String], ["prov1", "prov2"]) XCTAssertEqual(body["crossPostToBluesky"] as? Bool, true) XCTAssertEqual(body["crossPostToLinkedIn"] as? Bool, false) + XCTAssertEqual(body["crossPostToTwitter"] as? Bool, true) // G7 XCTAssertNotNil(body["scheduledAt"]) // ISO-8601 string emitted XCTAssertNil(body["parentId"]) // nil omitted } diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ModerationEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ModerationEndpointTests.swift new file mode 100644 index 0000000..c6507a9 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ModerationEndpointTests.swift @@ -0,0 +1,111 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the Moderation endpoint group (the-gaps.md G2). +final class ModerationEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + // MARK: - Builder shape assertions + + func test_givenModerationBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(Moderation.blocks().path, "/api/user/blocks") + XCTAssertEqual(Moderation.blocks().method, .get) + XCTAssertEqual(Moderation.mutes().path, "/api/user/mutes") + + XCTAssertEqual(Moderation.block(username: "ada").method, .post) + XCTAssertEqual(Moderation.block(username: "ada").path, "/api/users/ada/block") + XCTAssertEqual(Moderation.unblock(username: "ada").method, .delete) + XCTAssertEqual(Moderation.unblock(username: "ada").path, "/api/users/ada/block") + + XCTAssertEqual(Moderation.mute(username: "ada").method, .post) + XCTAssertEqual(Moderation.mute(username: "ada").path, "/api/users/ada/mute") + XCTAssertEqual(Moderation.unmute(username: "ada").method, .delete) + XCTAssertEqual(Moderation.unmute(username: "ada").path, "/api/users/ada/mute") + + XCTAssertEqual(Moderation.reportUser(username: "ada", reason: "spam").path, "/api/users/ada/report") + XCTAssertEqual(Moderation.reportUser(username: "ada", reason: "spam").method, .post) + XCTAssertEqual(Moderation.reportMessage(id: "m1", reason: "spam").path, "/api/messages/m1/report") + XCTAssertEqual(Moderation.blocks().auth, .bearer) + } + + // MARK: - Happy path (list decode) + + func test_givenBlocksBody_whenSent_thenDecodesUsersAndPagination() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"blockedUsers":[{"id":"u1","username":"spammer","displayName":"Spam Bot","avatar":"https://cdn/x.png"}], + "pagination":{"total":1,"limit":20,"offset":0,"hasMore":false}} + """#)) + + let response = try await client.send(Moderation.blocks()) + + XCTAssertEqual(response.blockedUsers.map(\.id), ["u1"]) + XCTAssertEqual(response.blockedUsers.first?.username, "spammer") + XCTAssertEqual(response.pagination?.total, 1) + } + + func test_givenMutesBody_whenSent_thenDecodesUsers() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"mutedUsers":[{"id":"u2"}],"pagination":{"total":1,"limit":20,"offset":0,"hasMore":false}}"#)) + + let response = try await client.send(Moderation.mutes()) + + XCTAssertEqual(response.mutedUsers.map(\.id), ["u2"]) + XCTAssertNil(response.mutedUsers.first?.username) + } + + // MARK: - Report body encoding + + func test_givenReasonAndDetail_whenReportUserSent_thenEncodesReportBody() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"ok":true}"#)) + + _ = try await client.send(Moderation.reportUser(username: "ada", reason: "harassment", detail: "context here")) + + let received = await transport.received + let body = try XCTUnwrap(received[0].httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["reason"] as? String, "harassment") + XCTAssertEqual(json?["detail"] as? String, "context here") + XCTAssertEqual(received[0].httpMethod, "POST") + } + + // MARK: - API failure + + func test_givenServerError_whenBlocksSent_thenThrowsHttpStatus() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"boom"}"#, status: 500)) + + do { + _ = try await client.send(Moderation.blocks()) + XCTFail("Expected httpStatus") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - Empty / boundary + + func test_givenNoBlocks_whenSent_thenReturnsEmptyList() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"blockedUsers":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"#)) + + let response = try await client.send(Moderation.blocks()) + + XCTAssertTrue(response.blockedUsers.isEmpty) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/SearchEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/SearchEndpointTests.swift new file mode 100644 index 0000000..16ec16a --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/SearchEndpointTests.swift @@ -0,0 +1,123 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the Search endpoint group (the-gaps.md G5). +final class SearchEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + private func messageJSON(id: String, content: String) -> String { + #""" + {"id":"\#(id)","content":"\#(content)","publiclyVisible":true,"userId":"u-1", + "createdAt":"2026-07-31T00:00:00Z","updatedAt":"2026-07-31T00:00:00Z", + "digCount":0,"pushCount":0,"dugByMe":false, + "user":{"id":"u-1","username":"ada","displayName":"Ada"}} + """# + } + + // MARK: - Builder shape assertions + + func test_givenSearchBuilders_whenConstructed_thenUseExpectedMethodPathAuthAndQuery() { + let m = Search.messages(query: "hello", limit: 10, offset: 5) + XCTAssertEqual(m.method, .get) + XCTAssertEqual(m.path, "/api/messages/search") + XCTAssertEqual(m.auth, .bearer) + XCTAssertEqual(m.query.first(where: { $0.name == "q" })?.value, "hello") + XCTAssertEqual(m.query.first(where: { $0.name == "limit" })?.value, "10") + XCTAssertEqual(m.query.first(where: { $0.name == "offset" })?.value, "5") + + XCTAssertEqual(Search.lists(query: "x").path, "/api/lists/search") + XCTAssertEqual(Search.lists(query: "x").method, .get) + XCTAssertEqual(Search.documents(query: "x").path, "/api/documents/search") + XCTAssertEqual(Search.documents(query: "x").method, .get) + } + + // MARK: - Happy path + + func test_givenMessagesBody_whenSearchSent_thenDecodesMessagesAndSendsQuery() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"messages":[\#(messageJSON(id: "m1", content: "hello world"))]}"#)) + + let response = try await client.send(Search.messages(query: "hello")) + + XCTAssertEqual(response.messages.map(\.id), ["m1"]) + XCTAssertNil(response.pagination) + let received = await transport.received + let comps = URLComponents(url: try XCTUnwrap(received[0].url), resolvingAgainstBaseURL: false) + XCTAssertTrue(comps?.queryItems?.contains(URLQueryItem(name: "q", value: "hello")) ?? false) + } + + func test_givenListsBodyWithPagination_whenSearchSent_thenDecodesListsAndPagination() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"lists":[{"id":"l1","title":"Bikes"}], + "pagination":{"total":1,"limit":20,"offset":0,"hasMore":false}} + """#)) + + let response = try await client.send(Search.lists(query: "bike")) + + XCTAssertEqual(response.lists.map(\.id), ["l1"]) + XCTAssertEqual(response.pagination?.total, 1) + } + + func test_givenDocumentsBody_whenSearchSent_thenDecodesDocuments() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"documents":[{"id":"d1","title":"Notes","content":"body"}]}"#)) + + let response = try await client.send(Search.documents(query: "notes")) + + XCTAssertEqual(response.documents.map(\.id), ["d1"]) + } + + // MARK: - API failure + + func test_givenServerError_whenSearchSent_thenThrowsHttpStatus() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"boom"}"#, status: 500)) + + do { + _ = try await client.send(Search.messages(query: "hello")) + XCTFail("Expected httpStatus") + } catch let error as APIError { + XCTAssertEqual(error, .httpStatus(code: 500, serverMessage: "boom")) + } + } + + // MARK: - Empty / boundary + + func test_givenNoHits_whenSearchSent_thenReturnsEmptyCollection() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"lists":[],"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"#)) + + let response = try await client.send(Search.lists(query: "zzz")) + + XCTAssertTrue(response.lists.isEmpty) + } + + func test_givenMalformedBody_whenSearchSent_thenThrowsDecodingError() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"messages":"not-an-array"}"#)) + + do { + _ = try await client.send(Search.messages(query: "hello")) + XCTFail("Expected decoding error") + } catch let error as APIError { + guard case .decoding = error else { + return XCTFail("Expected .decoding, got \(error)") + } + } + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/SharingEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/SharingEndpointTests.swift new file mode 100644 index 0000000..f6ea506 --- /dev/null +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/SharingEndpointTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import InterlinedKit + +/// BDD tests for the Sharing (share-links) endpoint group (the-gaps.md G3). +/// Fixtures mirror shapes captured live 2026-07-31. +final class SharingEndpointTests: XCTestCase { + + private let baseURL = URL(string: "https://stub.local")! + + private func makeClient( + transport: StubHTTPDataTransport = StubHTTPDataTransport(), + tokenStore: TokenStore = InMemoryTokenStore(initial: "il_tok_abc") + ) -> (APIClient, StubHTTPDataTransport) { + let auth = DefaultAuthTransport( + tokenStore: tokenStore, + sessionTransport: StubHTTPDataTransport(), + sessionEstablisher: NullSessionEstablisher() + ) + let client = APIClient(baseURL: baseURL, transport: transport, authTransport: auth) + return (client, transport) + } + + // MARK: - Builder shape assertions + + func test_givenSharingBuilders_whenConstructed_thenUseExpectedMethodPathAuth() { + XCTAssertEqual(Sharing.listShareLinks(listId: "l1").path, "/api/lists/l1/share-links") + XCTAssertEqual(Sharing.listShareLinks(listId: "l1").method, .get) + XCTAssertEqual(Sharing.createListShareLink(listId: "l1", CreateShareLinkRequest(role: "watcher")).method, .post) + XCTAssertEqual(Sharing.revokeListShareLink(listId: "l1", token: "t9").method, .delete) + XCTAssertEqual(Sharing.revokeListShareLink(listId: "l1", token: "t9").path, "/api/lists/l1/share-links/t9") + XCTAssertEqual(Sharing.resolveListShare(token: "t9").path, "/api/lists/shared/t9") + XCTAssertEqual(Sharing.claimListShare(token: "t9").method, .post) + + XCTAssertEqual(Sharing.documentShareLinks(documentId: "d1").path, "/api/documents/d1/share-links") + XCTAssertEqual(Sharing.resolveDocumentShare(token: "t9").path, "/api/documents/shared/t9") + XCTAssertEqual(Sharing.createDocumentShareLink(documentId: "d1", CreateShareLinkRequest(role: "manager")).path, "/api/documents/d1/share-links") + } + + // MARK: - Happy path + + func test_givenCreateBody_whenCreatingListLink_thenDecodesLinkAndEncodesRole() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"token":"tok9","url":"https://x/lists/shared/tok9","role":"watcher","expiresAt":null}"#)) + + let link = try await client.send(Sharing.createListShareLink(listId: "l1", CreateShareLinkRequest(role: "watcher"))) + + XCTAssertEqual(link.token, "tok9") + XCTAssertEqual(link.role, "watcher") + let received = await transport.received + let body = try XCTUnwrap(received[0].httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["role"] as? String, "watcher") + } + + func test_givenLinksBody_whenListing_thenDecodesRows() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"shareLinks":[{"token":"tok9","role":"collaborator","expiresAt":null, + "createdAt":"2026-07-31T23:07:24.019Z","revokedAt":null,"url":"https://x/s/tok9"}]} + """#)) + + let response = try await client.send(Sharing.listShareLinks(listId: "l1")) + + XCTAssertEqual(response.shareLinks.map(\.token), ["tok9"]) + XCTAssertEqual(response.shareLinks.first?.role, "collaborator") + } + + func test_givenResolveBody_whenResolvingListShare_thenDecodesRoleAndList() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#""" + {"role":"watcher","canClaim":false,"needsAuth":false, + "list":{"id":"l1","title":"Bikes","description":null,"isPublic":false,"updatedAt":"2026-07-18T19:14:35.099Z"}} + """#)) + + let resolved = try await client.send(Sharing.resolveListShare(token: "tok9")) + + XCTAssertEqual(resolved.role, "watcher") + XCTAssertEqual(resolved.list?.title, "Bikes") + } + + func test_givenRevokeBody_whenRevoking_thenDecodesRevoked() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"revoked":true}"#)) + + let response = try await client.send(Sharing.revokeListShareLink(listId: "l1", token: "tok9")) + + XCTAssertEqual(response.revoked, true) + } + + // MARK: - API failure + + func test_givenForbidden_whenCreatingLink_thenThrowsForbidden() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"error":"subscriber required"}"#, status: 403)) + + do { + _ = try await client.send(Sharing.createListShareLink(listId: "l1", CreateShareLinkRequest(role: "watcher"))) + XCTFail("Expected forbidden") + } catch let error as APIError { + XCTAssertEqual(error, .forbidden(serverMessage: "subscriber required")) + } + } + + // MARK: - Empty / boundary + + func test_givenNoLinks_whenListing_thenReturnsEmpty() async throws { + let (client, transport) = makeClient() + await transport.enqueue(.json(#"{"shareLinks":[]}"#)) + + let response = try await client.send(Sharing.documentShareLinks(documentId: "d1")) + + XCTAssertTrue(response.shareLinks.isEmpty) + } +} diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift index 972e18f..f22b5e0 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift @@ -331,6 +331,30 @@ final class UserEndpointTests: XCTestCase { let request = User.lookup(handle: "") XCTAssertTrue(request.query.contains { $0.name == "handle" && $0.value == "" }) } + + // MARK: - publicProfile (GET /api/users/{username}) — D2 + + func test_givenPublicProfileBuilder_whenBuilt_thenGetsUsersPathWithBearer() { + let request = User.publicProfile(username: "ada") + XCTAssertEqual(request.method, .get) + XCTAssertEqual(request.path, "/api/users/ada") + XCTAssertEqual(request.auth, .bearer) + } + + func test_givenProfileBody_whenPublicProfileSent_thenDecodesRichFields() async throws { + let (client, transport, _) = makeClient() + await transport.enqueue(.json(#""" + {"id":"u1","username":"ada","displayName":"Ada","avatar":null,"headerImage":null, + "bio":"hi","joinedAt":"2026-03-23T23:23:59.755Z","isPrivate":false, + "followerCount":3,"followingCount":1,"publicMessageCount":10,"publicListCount":0} + """#)) + + let profile = try await client.send(User.publicProfile(username: "ada")) + + XCTAssertEqual(profile.id, "u1") + XCTAssertEqual(profile.followerCount, 3) + XCTAssertEqual(profile.bio, "hi") + } } private struct AnyEncodableUserProbe: Encodable { diff --git a/blocker-prompts.md b/blocker-prompts.md index daef42d..293ba57 100644 --- a/blocker-prompts.md +++ b/blocker-prompts.md @@ -30,6 +30,13 @@ Last probed: 2026-07-07. Updated: 2026-07-08. | P3-C | `lastRefreshedAt` + `refreshStatus` on lists + `githubSource` on POST | ❌ Open | | P3-D | Token revocation + `GET /api/user/sessions` | ❌ Open | | P3-E | `RateLimit-*` headers universally | 🟡 Partial — on 2 routes only; macOS nil-guards correctly | +| P1-G | Following / home feed endpoint | ❌ Open — client UI wired, short-circuits to empty (added 2026-07-18) | +| P1-H | GitHub issue create/comment + labels/assignees | ❌ Open — largest parity gap; extends P3-C (added 2026-07-18) | +| P2-F | Markdown export format / per-item export | ❌ Open — client renders MD itself for now (added 2026-07-18) | +| P2-G | Schema DSL `select`/`markdown` token spec | ❌ Open — client shipped both; needs token/validation confirm (added 2026-07-18) | +| P3-F | Link-preview `fetchStatus` value docs | ❌ Open — client renders previews; gate is forward-compatible (added 2026-07-18) | +| P3-G | List "save to my lists" clone-with-rows | ❌ Open — copies metadata+schema only (added 2026-07-18) | +| P3-H | Message edit verb: `PATCH` (docs) vs `PUT` (client) | ❌ Open — reconcile reference and client (added 2026-07-18) | --- @@ -228,10 +235,73 @@ Add `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` to every authent --- +## Open — Web-parity pass (added 2026-07-18) + +Surfaced by the 2026-07-18 feature-parity review against interlinedlist.com/features. P1-G and P1-H unlock the most user-visible parity. + +### P1-G — Following / home feed endpoint + +**Status:** The macOS client's `TimelineScope.following` is fully UI-wired (All / Mine / Following picker), but `MessagesService.timeline` short-circuits `.following` to an empty page because no endpoint exists — it shows a "coming soon" empty state. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Add a followed-accounts timeline feed. Preferred: extend `GET /api/messages` with `?scope=following` (or add `GET /api/feed/following`), returning only messages authored by accounts the caller follows, using the **same paginated envelope** as `GET /api/messages` (same `limit`/`offset`/`hasMore` shape). Bearer auth. Document it. The macOS client already has the UI wired and flips one branch to consume it. + +### P1-H — GitHub issue create/comment + labels/assignees + +**Status:** The client has a read-only `GitHubListSource` projection refreshed manually. P3-C covers refresh *metadata*; this covers issue **writes** and **labels/assignees**, which the site advertises ("create and comment on issues within platform," "automatic label and assignee pulling"). + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Extend GitHub-synced lists so the macOS client can reach the advertised issue features. Define and document: (1) issue **labels** and **assignees** as fields on GitHub-sourced list rows; (2) an endpoint to **create a GitHub issue** from a synced list; (3) an endpoint to **comment on an issue**. Provide the request/response shapes. Coordinate with P3-C (refresh metadata + `githubSource` on create). + +### P2-F — Markdown export format / per-item export + +**Status:** `/api/exports/*` returns CSV only (no format negotiation, no per-item endpoints). The macOS client now renders Markdown itself from domain models (`MarkdownExporter`), which requires N+1 refetches for bulk export. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Add Markdown export. (1) A format param on the four export endpoints, e.g. `GET /api/exports/lists?format=md` (or `Accept: text/markdown`), returning Markdown. (2) Per-resource export endpoints: `GET /api/documents/[id]/export?format=md`, `GET /api/messages/[id]/thread/export?format=md`, `GET /api/lists/[id]/export?format=md`. Lists should render as Markdown tables ("structured table conversion"). + +### P2-G — Schema DSL `select`/`markdown` token spec + +**Status:** The macOS client shipped `select` and `markdown` schema field types (2026-07-18). The DSL type taxonomy has never been enumerated by the API (this is the old `API-backend-prompts-to-build.md` item 2.2). The schema crosses the wire as a DSL string round-tripping through the client's parser only, so these client assumptions are **unverified**. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Document and confirm the list schema DSL type tokens the macOS client now emits: (1) **`select`** with an ordered option set — the client uses `Field:select(a|b|c)` (token `select`, `(...)` wrapper, `|` delimiter). Confirm the token, the delimiter, and whether the server persists and re-emits the option list verbatim on `GET .../schema` or normalizes it. (2) **`markdown`** — confirm the cell value is a plain JSON string of raw Markdown. (3) Confirm the server accepts the existing **`email`** token on `PUT .../schema`. + +### P3-F — Link-preview `fetchStatus` value docs + +**Status:** The server returns `linkMetadata.links[].fetchStatus`; the client now renders preview cards and gates on a forward-compatible set of "ready-ish" values plus title/image presence. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Document the closed value set for `fetchStatus` on message `linkMetadata.links[]` — specifically which value means "preview ready to show" vs. "still fetching" vs. "failed" — so the macOS client can gate rendering on the authoritative token(s). + +### P3-G — List "save to my lists" clone-with-rows + +**Status:** `ListDetailViewModel.saveToMyLists` copies metadata + schema only; a documented degradation because no clone endpoint exists. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Add `POST /api/lists/[id]/clone` (or a rows-copy option on save) that duplicates a public list's rows into a new owned list, so "save to my lists" can carry the data, not just the schema. + +### P3-H — Message edit verb reconciliation (`PATCH` vs `PUT`) + +**Status:** The API reference documents `PATCH /api/messages/[id]` for message edit; the shipped macOS client issues `PUT` (both work live). Reference and client disagree. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). The API reference documents message edit as `PATCH /api/messages/[id]`, but the macOS client sends `PUT` and it works. Confirm the canonical verb and either update the reference to match the live behavior or document that both are accepted. + +--- + ## Change log | Date | Change | |------|--------| +| 2026-07-18 | Web-parity pass: added P1-G (following feed), P1-H (GitHub issue writes), P2-F (Markdown export), P2-G (schema DSL select/markdown), P3-F (link-preview fetchStatus), P3-G (list clone), P3-H (PATCH/PUT). Confirmed P1-A/P1-C/P1-D still resolved + wired via code (scheduled cancel/reschedule, bluesky/mastodon readiness, watcher/org invite-by-handle). | | 2026-07-08 | Recreated from `API-backend-prompts-to-build.md` + `Backend-Handoff-Prompts.md` (both deleted). Marked P1-A, P1-B, P1-C, P1-D, P2-A resolved — confirmed via live probe and macOS NW features complete. | | 2026-07-07 | Live probe: P2-A resolved, P1-A endpoints exist (not 404), P3-E still absent. | | 2026-07-04 | `Backend-Handoff-Prompts.md` authored with copy-paste prompts. | diff --git a/docs/api-coverage.md b/docs/api-coverage.md index c05c546..297631f 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -1,5 +1,7 @@ # API Endpoint Coverage Matrix +> **Re-baselined 2026-07-31 against the live `openapi.json` (~150 endpoints).** The **original 98 rows** below cover the 2026-06-11 API surface and keep their real ☑/◐/☐ implementation-and-test state unchanged. The live API has since grown to **~150 endpoints** across whole new feature areas the app has not yet implemented; those are captured in the new **[New endpoints (2026-07-31 re-baseline)](#new-endpoints-2026-07-31-re-baseline--not-yet-implemented)** section, each starting ☐/☐ and mapped to its gap ID (G1–G14) in **[`the-gaps.md`](../the-gaps.md) §1**. This file remains the home for the per-endpoint ☑/◐ **test** matrix; the maintenance rule below still governs when a new row may flip. + **Audience:** engineering (maintainers and implementing agents). This matrix exists so that full coverage of the [InterlinedList API](https://interlinedlist.com/help/api) is **verified, not assumed** (PLAN.md §7). It maps every documented endpoint to the service planned to implement it (PLAN.md §3) and the milestone that ships it (PLAN.md §6), with check-off columns for implementation and tests. @@ -112,7 +114,180 @@ This matrix exists so that full coverage of the [InterlinedList API](https://int | `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. +**Original-surface 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. + +## New endpoints (2026-07-31 re-baseline) — not yet implemented + +The 2026-07-31 authenticated live probe ([`the-gaps.md`](../the-gaps.md) §2 + appendix) plus `GET /api/openapi.json` show the surface has grown to ~150 endpoints across new feature areas the app has never implemented. Every endpoint below is **absent** from the original 98-row matrix; each starts **Implemented ☐ / Tested ☐** and maps to the gap ID (G1–G14) in [`the-gaps.md`](../the-gaps.md) §1. **Backend** column: ✅ = confirmed live & Bearer-reachable in the 2026-07-31 probe; ⚠️ = live but constrained; *per OpenAPI, unverified* = present in the spec / named in the gap plan but **not** individually hit in the read-only probe (writes were deliberately not exercised). Rows flip ◐→☑ only under the same maintenance rule (a tested App-layer view model drives them end-to-end). + +### Direct Messages (G1) — 11 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/dm` | Direct Messages | ✅ | G1 | List DMs by folder (inbox/sent/deleted), cursor-paginated | ☐ | ☐ | +| `POST /api/dm` | Direct Messages | ✅ | G1 | Send a DM to a mutual follower (≤8 image attachments) | ☐ | ☐ | +| `POST /api/dm/images/upload` | Direct Messages | ✅ | G1 | Upload an image for a DM | ☐ | ☐ | +| `GET /api/dm/recipients` | Direct Messages | ✅ | G1 | List eligible DM recipients (mutual followers) | ☐ | ☐ | +| `GET /api/dm/thread/{username}` | Direct Messages | ✅ | G1 | Fetch the conversation thread with a user | ☐ | ☐ | +| `GET /api/dm/thread/{username}/updates` | Direct Messages | ✅ | G1 | Poll for new messages in a thread since a marker | ☐ | ☐ | +| `GET /api/dm/unread-count` | Direct Messages | ✅ | G1 | Unread-DM count for the badge | ☐ | ☐ | +| `GET /api/dm/{id}` | Direct Messages | ✅ | G1 | Fetch a single DM | ☐ | ☐ | +| `POST /api/dm/{id}/read` | Direct Messages | ✅ | G1 | Mark a DM read | ☐ | ☐ | +| `POST /api/dm/{id}/restore` | Direct Messages | ✅ | G1 | Restore a trashed DM (per-side) | ☐ | ☐ | +| `POST /api/dm/{id}/trash` | Direct Messages | ✅ | G1 | Soft-delete a DM (per-side) | ☐ | ☐ | + +### Moderation (G2) — 10 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/user/blocks` | Moderation | ✅ | G2 | List blocked users (paginated) | ☐ | ☐ | +| `POST /api/user/blocks` | Moderation | per OpenAPI, unverified | G2 | Block a user | ☐ | ☐ | +| `DELETE /api/user/blocks/{username}` | Moderation | per OpenAPI, unverified | G2 | Unblock a user | ☐ | ☐ | +| `GET /api/user/blocks/{username}` | Moderation | per OpenAPI, unverified | G2 | Is-blocking status for a user | ☐ | ☐ | +| `GET /api/user/mutes` | Moderation | ✅ | G2 | List muted users (paginated) | ☐ | ☐ | +| `POST /api/user/mutes` | Moderation | per OpenAPI, unverified | G2 | Mute a user | ☐ | ☐ | +| `DELETE /api/user/mutes/{username}` | Moderation | per OpenAPI, unverified | G2 | Unmute a user | ☐ | ☐ | +| `GET /api/user/mutes/{username}` | Moderation | per OpenAPI, unverified | G2 | Is-muting status for a user | ☐ | ☐ | +| `POST /api/reports/user` | Moderation | per OpenAPI, unverified | G2 | Report a user (reason + detail) | ☐ | ☐ | +| `POST /api/reports/message` | Moderation | per OpenAPI, unverified | G2 | Report a message (reason + detail) | ☐ | ☐ | + +### Share Links & Collaborators (G3) — 17 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/lists/{id}/share-links` | Share Links & Collaborators | ✅ | G3 | List a list's tokenized share links | ☐ | ☐ | +| `POST /api/lists/{id}/share-links` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Create a share link (role + expiry, subscriber-gated) | ☐ | ☐ | +| `DELETE /api/lists/{id}/share-links/{token}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Revoke a list share link | ☐ | ☐ | +| `GET /api/lists/shared/{token}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Resolve a shared list by token (read-only viewer) | ☐ | ☐ | +| `POST /api/lists/shared/{token}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Claim a shared list link | ☐ | ☐ | +| `GET /api/lists/shared/{token}/data` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Read shared-list row data by token | ☐ | ☐ | +| `GET /api/lists/watching` | Share Links & Collaborators | ✅ | G3 | "Shared-with-me" lists the user is watching | ☐ | ☐ | +| `GET /api/documents/{id}/share-links` | Share Links & Collaborators | ✅ | G3 | List a document's share links | ☐ | ☐ | +| `POST /api/documents/{id}/share-links` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Create a document share link (subscriber-gated) | ☐ | ☐ | +| `DELETE /api/documents/{id}/share-links/{token}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Revoke a document share link | ☐ | ☐ | +| `GET /api/documents/shared/{token}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Resolve a shared document by token | ☐ | ☐ | +| `POST /api/documents/shared/{token}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Claim a shared document link | ☐ | ☐ | +| `GET /api/documents/{id}/collaborators` | Share Links & Collaborators | ✅ | G3 | List per-person document collaborators (paginated) | ☐ | ☐ | +| `POST /api/documents/{id}/collaborators` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Add a document collaborator (by @handle + role) | ☐ | ☐ | +| `GET /api/documents/{id}/collaborators/users` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Search users for collaborator invite | ☐ | ☐ | +| `PUT /api/documents/{id}/collaborators/{userId}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Set a collaborator's role | ☐ | ☐ | +| `DELETE /api/documents/{id}/collaborators/{userId}` | Share Links & Collaborators | per OpenAPI, unverified | G3 | Remove a document collaborator | ☐ | ☐ | + +### List Folders (G6) — 4 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/folders` | List Folders | ✅ | G6 | List hierarchical list-folders (flat array + `parentId`) | ☐ | ☐ | +| `POST /api/folders` | List Folders | per OpenAPI, unverified | G6 | Create a list-folder (subscriber-gated) | ☐ | ☐ | +| `PUT /api/folders/{id}` | List Folders | per OpenAPI, unverified | G6 | Rename / move a list-folder (cycle-safe) | ☐ | ☐ | +| `DELETE /api/folders/{id}` | List Folders | per OpenAPI, unverified | G6 | Delete a list-folder (detaches lists to root) | ☐ | ☐ | + +### Search (G5) — 3 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/messages/search` | Search | ✅ | G5 | Server-side message search (`?q=`; POST → 405, search is GET) | ☐ | ☐ | +| `GET /api/lists/search` | Search | ✅ | G5 | Server-side list search | ☐ | ☐ | +| `GET /api/documents/search` | Search | ✅ | G5 | Server-side document search | ☐ | ☐ | + +### GitHub (G4) — 8 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/github/repos` | GitHub | ⚠️ | G4 | List linked-account repos (400 "not linked" until OAuth link) | ☐ | ☐ | +| `GET /api/github/issues` | GitHub | per OpenAPI, unverified | G4 | List issues for a repo | ☐ | ☐ | +| `POST /api/github/issues` | GitHub | per OpenAPI, unverified | G4 | Create an issue | ☐ | ☐ | +| `PATCH /api/github/issues/{owner}/{repo}/{number}` | GitHub | per OpenAPI, unverified | G4 | Edit an issue (labels / assignees / state) | ☐ | ☐ | +| `POST /api/github/issues/{owner}/{repo}/{number}/comments` | GitHub | per OpenAPI, unverified | G4 | Comment on an issue | ☐ | ☐ | +| `GET /api/github/repos/{owner}/{repo}/assignees` | GitHub | per OpenAPI, unverified | G4 | List assignable users for a repo | ☐ | ☐ | +| `GET /api/github/repos/{owner}/{repo}/labels` | GitHub | per OpenAPI, unverified | G4 | List labels for a repo | ☐ | ☐ | +| `GET /api/github/repos/{owner}/{repo}/next-issue-number` | GitHub | per OpenAPI, unverified | G4 | Next issue number for a repo | ☐ | ☐ | + +### Push (G9) — 2 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `POST /api/push/register` | Push | ✅ | G9 | Register an APNs device token (400 "token is required" when empty → route live) | ☐ | ☐ | +| `DELETE /api/push/unregister` | Push | ⚠️ | G9 | Unregister a device token (POST → 405; verb likely DELETE — confirm) | ☐ | ☐ | + +### Stripe / Billing (G8) — 2 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `POST /api/stripe/checkout-session` | Stripe / Billing | **404 not deployed** | ~~G8~~ **OUT OF SCOPE** | Billing managed in the online app (owner decision 2026-07-31); route also 404s live | — | — | +| `GET /api/stripe/customer-portal-session` | Stripe / Billing | **404 not deployed** | ~~G8~~ **OUT OF SCOPE** | Billing managed in the online app; route also 404s live | — | — | + +### LinkedIn targets (G11a) — 4 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/linkedin/targets` | LinkedIn targets | ✅ | G11a | List LinkedIn posting targets (personal target present) | ☐ | ☐ | +| `GET /api/linkedin/posting-targets` | LinkedIn targets | ✅ | G11a | Read enabled posting targets (`enabled:true`, `orgScopeMissing:true`) | ☐ | ☐ | +| `PUT /api/linkedin/posting-targets` | LinkedIn targets | per OpenAPI, unverified | G11a | Set enabled posting targets | ☐ | ☐ | +| `POST /api/linkedin/sync-pages` | LinkedIn targets | per OpenAPI, unverified | G11a | Refresh available LinkedIn pages | ☐ | ☐ | + +### Twitter / X auth (G7) — 3 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/auth/twitter/authorize` | Twitter / X auth | per OpenAPI, unverified | G7 | Begin X/Twitter OAuth authorization | ☐ | ☐ | +| `GET /api/auth/twitter/callback` | Twitter / X auth | per OpenAPI, unverified | G7 | X/Twitter OAuth callback | ☐ | ☐ | +| `GET /api/auth/twitter/status` | Twitter / X auth | ✅ | G7 | X/Twitter link status (`configured:true`) | ☐ | ☐ | + +### Document templates & tree (G12) — 6 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/documents/templates` | Document templates & tree | ✅ | G12 | List server-side document templates (seeded + `_templates` folder) | ☐ | ☐ | +| `POST /api/documents/templates/seed-defaults` | Document templates & tree | per OpenAPI, unverified | G12 | Seed the default template set | ☐ | ☐ | +| `GET /api/documents/from-template` | Document templates & tree | per OpenAPI, unverified | G12 | Preview a new document from a template | ☐ | ☐ | +| `POST /api/documents/from-template` | Document templates & tree | per OpenAPI, unverified | G12 | Create a document from a template | ☐ | ☐ | +| `GET /api/documents/tree` | Document templates & tree | ✅ | G12 | One-call folders + documents sidebar payload | ☐ | ☐ | +| `POST /api/documents/folders/{id}/documents` | Document templates & tree | per OpenAPI, unverified | G12 | Create a document directly inside a folder | ☐ | ☐ | + +### Document presence (G13) — 2 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `POST /api/documents/{id}/presence` | Document presence | per OpenAPI, unverified | G13 | Send a live co-editing presence heartbeat | ☐ | ☐ | +| `DELETE /api/documents/{id}/presence` | Document presence | per OpenAPI, unverified | G13 | Clear presence on leaving a document | ☐ | ☐ | + +### Utility / limits (G14) — 2 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/limits` | Utility / limits | ✅ | G14 | Quota / media limits (drives composer validation + plan card) | ☐ | ☐ | +| `GET /api/images/proxy` | Utility / limits | per OpenAPI, unverified | G14 | Image-proxy helper (rich previews / avatars) | ☐ | ☐ | + +### Multi-account (G10) — 3 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/auth/accounts` | Multi-account | ⚠️ | G10 | List switchable accounts (**401 under Bearer — session-cookie-only**; spike S4) | ☐ | ☐ | +| `POST /api/auth/switch` | Multi-account | ⚠️ | G10 | Switch the active account (session-bound) | ☐ | ☐ | +| `POST /api/auth/remove-account` | Multi-account | per OpenAPI, unverified | G10 | Remove a linked account | ☐ | ☐ | + +### Public profile & multi-account (migrations D2 / OAuth-link) — 2 + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `GET /api/users/{username}` | Public profile | ✅ | D2 (fn 8) | Direct public-profile read (now live — replaces the decision-0002 fallback) | ☐ | ☐ | +| `POST /api/auth/{provider}/link` | Auth (OAuth) | ✅ | fn 12 | Bearer native OAuth identity-link completion (endpoint live; native flow built on this branch) | ☐ | ☐ | + +### Messages & auth drift additions (D1 / D3 / new methods on existing paths) — 4 + +New HTTP methods / paths on already-listed resource families, surfaced by the re-baseline (see [`the-gaps.md`](../the-gaps.md) §6): + +| Endpoint (method + path) | Group | Backend | Gap | Purpose | Implemented | Tested | +| --- | --- | --- | --- | --- | --- | --- | +| `POST /api/messages/scheduled` | Messages | per OpenAPI, unverified | D1 | Explicit schedule-create (app uses `scheduledAt`-on-create — verify preferred) | ☐ | ☐ | +| `POST /api/messages/{id}/replies` | Messages | per OpenAPI, unverified | D3 | Post a reply via POST (app currently reads replies via GET — align verbs) | ☐ | ☐ | +| `POST /api/lists/{id}/watchers` | Lists | per OpenAPI, unverified | — | Invite a watcher via POST (matrix has `PUT …/watchers/{userId}`) | ☐ | ☐ | +| `POST /api/auth/verify-email-change` | Auth | per OpenAPI, unverified | — | Confirm a pending email-change (pairs with existing `change-email/request`) | ☐ | ☐ | + +**New-endpoints subtotal:** 53 rows — Direct Messages 11 · Moderation 10 · Share Links & Collaborators 17 · List Folders 4 · Search 3 · GitHub 8 · Push 2 · Stripe/Billing 2 · LinkedIn targets 4 · Twitter/X auth 3 · Document templates & tree 6 · Document presence 2 · Utility/limits 2 · Multi-account 3 · Public profile & OAuth-link (D2 / fn 12) 2 · Messages & auth drift additions 4. + +**Re-baseline grand total:** **98 original + 53 new = 151 endpoints** (~150 as reported in [`the-gaps.md`](../the-gaps.md) §1). Original 98 keep their real implementation/test state (98 implemented; 74 ☑ / 18 ◐ / 6 ☐ tested as of Wave 8); all 53 new rows start ☐ Implemented / ☐ Tested. ## Footnotes and assumptions @@ -123,11 +298,11 @@ This matrix exists so that full coverage of the [InterlinedList API](https://int 5. `POST /api/auth/login` (cookie-session credential exchange) was deferred through Waves 1–7 (`NullSessionEstablisher` stub). **Resolved Wave 8.1 (2026-07-03):** `LiveSessionEstablisher` + `CredentialStore` + `KeychainCredentialStore` now implement the lazy `POST /api/auth/login` path; `AuthService.signIn` persists credentials to `KeychainCredentialStore` so the establisher can re-authenticate on the next `.session` call. `LiveSessionEstablisherTests` covers the full quartet (happy 200/204, no-credentials, server 401, server 500, transport failure). Row flipped to ☑/☑; this footnote is resolved. 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. +8. **~~No public profile read endpoint exists on the live API.~~ RESOLVED 2026-07-31 — the endpoint now exists.** *(Historical:* the 2026-06-21 kit-gap spike found every variation of `GET /api/users/[username]` returned 404, so `SocialService.profile(username:)` fell back — per decision [`0002-public-profile-fallback`](decisions/0002-public-profile-fallback.md) — to the embedded `{ id, username, displayName, avatar }` author object on the first message from `GET /api/user/[username]/messages`.*)* The 2026-07-31 live probe ([`the-gaps.md`](../the-gaps.md) §2/§6, appendix) confirms `GET /api/users/{username}` now returns a **real public profile** (`/api/users/messenger` → 200). The direct-read row is added in the [New endpoints re-baseline](#new-endpoints-2026-07-31-re-baseline--not-yet-implemented) section under **Public profile & multi-account (migration D2)** at ☐/☐; migration **D2** ([`the-gaps.md`](../the-gaps.md) §6) tracks replacing the decision-0002 fallback with the direct call (keep the fallback only for pre-migration servers). When that row is implemented and view-model-tested it flips per the maintenance rule. 9. **M3 reachable but not exercised by a tested App-layer view model this wave.** Per Wave 1 footnote 4, a row only flips ◐⁴ → ☑ when an App-layer consumer drives it end-to-end under test. Four Lists rows are wired through `ListsService` and reachable from the running app but their consuming UX was held back to a polish slice this wave: `GET /api/lists/[id]` and `PUT /api/lists/[id]` (the detail-rename / single-list-refresh paths — rename UX deferred), `GET /api/lists/[id]/data/[rowId]` (single-row hydration — `RowInspectorView` reads from the already-paginated `ListRowsViewModel.rows` array), and `GET /api/lists/[id]/watchers` (the watcher pagination envelope — `WatchersView` consumes `/users` only this wave). These rows stay ◐⁴ until the next M3 polish wave consumes them through a tested view model. The Wave 1 footnote-4 backfill rule still applies. 10. **M4 detail-read rows reachable but not view-model-tested this wave.** Same pattern as footnote 9, applied to Documents. `GET /api/documents/[id]` and `GET /api/documents/folders/[id]` are wired through `DocumentsService.document(id:)` / `DocumentsService.folder(id:)` and reachable from the running app, but the Wave 5.3 App-layer view models (`DocumentsListViewModel`, `DocumentEditorViewModel`, `FolderTreeViewModel`) consume documents and folders from the **list** payload (`GET /api/documents`, `GET /api/documents/folders[/[id]/documents]`) and the **sync delta** payload rather than re-reading by id. The detail-read endpoints stay ◐⁴ until a polish slice consumes them through a tested view-model path (a likely candidate: a single-document deep-link / quick-look refresh, or a focused folder-rename inspector that re-hydrates from `folder(id:)`). The Wave 1 footnote-4 backfill rule still applies. 11. **M5 follower-removal reachable but not view-model-tested this wave.** Same pattern as footnotes 9 and 10, applied to Follow. `POST /api/follow/[userId]/remove` (the "remove a user from **my** followers" action — distinct from `DELETE /api/follow/[userId]`, which unfollows someone I follow) is wired through `SocialService.removeFollower(userId:)` and reachable from the running app, but no Wave 6.3 view model exercises it through a tested path: the Followers tab in `SocialRosterRootView` displays the roster and approves/rejects pending requests, but does not yet surface a "remove this follower" action against an already-accepted follower. The row stays ◐⁴ until a polish slice (most likely a `SocialRosterRowViewModel.removeFollower` action behind a context menu on the Followers tab) consumes it. The Wave 1 footnote-4 backfill rule still applies. -12. **OAuth `authorize` builders Implemented but not end-to-end consumable — by design (Wave 7).** The five M6 OAuth rows (`GET /api/auth/{github,mastodon,bluesky,linkedin}/authorize` and `GET /api/auth/linkedin/status`) gained Kit request builders this wave (`Auth.authorize(provider:link:instance:)`, `Auth.linkedinStatus()`, the `OAuthProvider` enum, and the `LinkedInStatusResponse` DTO, with 13 builder tests), so their **Implemented** column flips ☐ → ☑. Their **Tested** column stays untested (☐¹²) — *not* ◐⁴ — because native completion of the flow is **blocked upstream** ([spike 0002](spikes/0002-oauth-identity-linking.md), [decision 0006](decisions/0006-oauth-identity-linking-browser-handoff.md)): the `/authorize` callback is a web URL on `interlinedlist.com` (no custom scheme / universal link), the flow is cookie-bound (not Bearer), and there is no bearer `…/link` endpoint a native client can complete against. The app therefore does **not send** these requests; it **opens the `…/authorize?link=true` URL in the default browser** for completion on the web. The four `…/authorize` rows are reached only *indirectly* — `UserService.identityLinkURL(provider:instance:)` resolves the `Auth.authorize` builder to a `URL` that `LinkedAccountsView` hands to SwiftUI `@Environment(\.openURL)` — so there is no app-side send to test against; `GET /api/auth/linkedin/status` is implemented but currently **unconsumed**. The Tested column flips to ☑ only when an upstream native-completion contract lands (custom-scheme / universal-link callback, or a bearer `POST /api/auth/{provider}/link`) and the app sends/completes the flow itself — tracked in `NEXT-WORK.md` NW-5 and `API-backend-prompts-to-build.md` ask 2.6. +12. **OAuth `authorize` builders Implemented (Wave 7).** The five M6 OAuth rows (`GET /api/auth/{github,mastodon,bluesky,linkedin}/authorize` and `GET /api/auth/linkedin/status`) gained Kit request builders in Wave 7 (`Auth.authorize(provider:link:instance:)`, `Auth.linkedinStatus()`, the `OAuthProvider` enum, and the `LinkedInStatusResponse` DTO, with 13 builder tests), so their **Implemented** column is ☑. **UPDATE 2026-07-31 — native OAuth identity linking is now BUILT on `feature/web-parity-batch-2026-07`, so the "blocked upstream" note is resolved** ([`the-gaps.md`](../the-gaps.md) §3): `Auth.linkIdentity` → `POST /api/auth/{provider}/link`, `UserService.linkIdentityNative`, a registered `interlinedlist://oauth/callback` custom scheme, and `ASWebAuthenticationSession` now let the app complete the flow natively rather than only handing `…/authorize?link=true` to the browser. The bearer `POST /api/auth/{provider}/link` completion endpoint that footnote 12 said "does not exist" is live and consumed; the new `POST /api/auth/{provider}/link` row is added in the [New endpoints re-baseline](#new-endpoints-2026-07-31-re-baseline--not-yet-implemented) section under **Public profile & multi-account**. The five original `authorize`/`status` rows keep their historical Tested ☐ state here (their per-endpoint completion tests are backfilled with the native-linking work); flips follow the maintenance rule once a view-model test drives them end-to-end. 13. **M6 organization-read rows reachable but not view-model-tested this wave.** Same pattern as footnotes 9, 10, and 11, applied to Organizations. Two OrgService read rows are wired and reachable but not driven by a tested App-layer view model this wave: `GET /api/organizations` (the *list-all-orgs* variant) — the Wave 7.3 Organizations UI lists the current user's orgs through `UserService.organizations()` (`GET /api/user/organizations`) instead, so the `OrgService` list-all path stays unconsumed; and `GET /api/organizations/[id]/users` (`OrgService.users(of:)`) — the member roster is rendered from `GET /api/organizations/[id]/members` (`OrgMembersViewModel`), leaving the `/users` projection unconsumed. Both rows stay ◐⁴ until a polish slice consumes them through a tested view model. The Wave 1 footnote-4 backfill rule still applies. ## Cross-check against PLAN.md §1 (2026-06-11) @@ -139,6 +314,8 @@ This matrix exists so that full coverage of the [InterlinedList API](https://int ## Update history +- **2026-07-31 — Re-baseline against live `openapi.json` (~150 endpoints).** The matrix was stale at 98 endpoints (2026-06-11 surface); the live API has grown to ~150 across new feature areas. Re-baselined per [`the-gaps.md`](../the-gaps.md) §8: (1) the intro banner now states the ~150-endpoint re-baseline; (2) all **98 original rows keep their real ☑/◐/☐ implementation-and-test state unchanged** (98 implemented; 74 ☑ / 18 ◐ / 6 ☐ tested); (3) a new **"New endpoints (2026-07-31 re-baseline)"** section adds **53 rows** — all ☐ Implemented / ☐ Tested — grouped by feature area and mapped to gap IDs G1–G14: Direct Messages (G1) 11, Moderation (G2) 10, Share Links & Collaborators (G3) 17, List Folders (G6) 4, Search (G5) 3, GitHub (G4) 8, Push (G9) 2, Stripe/Billing (G8) 2, LinkedIn targets (G11a) 4, Twitter/X auth (G7) 3, Document templates & tree (G12) 6, Document presence (G13) 2, Utility/limits (G14) 2, Multi-account (G10) 3, Public profile & OAuth-link (D2 / fn 12) 2, and Messages & auth drift additions (D1/D3) 4. Each new row carries a **Backend** marker: ✅ confirmed live in the 2026-07-31 authenticated probe, ⚠️ live-but-constrained, or *per OpenAPI, unverified* (spec-listed / gap-planned but not individually hit read-only). **Footnote 8 RESOLVED** — `GET /api/users/{username}` public profile now exists (verified live, migration D2); the direct-read row is added and the decision-0002 fallback is slated for replacement. **Footnote 12 RESOLVED** — native OAuth identity linking (`POST /api/auth/{provider}/link` via `Auth.linkIdentity` + `ASWebAuthenticationSession` + `interlinedlist://oauth/callback`) is BUILT on `feature/web-parity-batch-2026-07`; the "blocked upstream" note no longer applies and the `…/link` row is added. **Grand total: 98 original + 53 new = 151 endpoints (~150).** No original row's ☑/◐/☐ mark was changed; new rows flip only under the existing maintenance rule (a tested App-layer view model drives them end-to-end). + - **2026-07-03 — Wave 8 update (M7 Ship: LiveSessionEstablisher, Exports E2E, Settings/Account E2E).** Wave 8.1 landed `LiveSessionEstablisher` (`CredentialStore` protocol + `KeychainCredentialStore` production + `InMemoryCredentialStore` tests) — the real `POST /api/auth/login` cookie-session fallback that was stubbed via `NullSessionEstablisher` since Wave 1. `AuthService.signIn` now persists credentials to Keychain so the establisher can re-authenticate lazily; `AppEnvironment.live()` wired with a dedicated ephemeral `URLSession` (isolated cookie jar). `LiveSessionEstablisherTests` covers the full quartet (6 new Kit tests; InterlinedKit suite 190 → 196). Wave 8.2 added `ExportViewModelTests` (8 tests) + `StubExportsService` to the App test suite, exercising all four export paths end-to-end through the view model. Wave 8.3 confirmed `AccountViewModelTests` (11 tests) already in the suite, covering avatar upload, email-change, account deletion, and sign-out quartet. Wave 8.0 (NW probe) confirmed all 6 NW-blocked items remain upstream-blocked; `NEXT-WORK.md` probe log appended. **Rows flipped ◐⁴ → ☑ this wave (7 total):** `GET /api/exports/messages`, `GET /api/exports/lists`, `GET /api/exports/list-data-rows`, `GET /api/exports/follows` (via `ExportViewModel` → `ExportsServicing` end-to-end with `ExportViewModelTests`), `POST /api/user/avatar/upload`, `POST /api/user/change-email/request`, `POST /api/user/delete` (via `AccountViewModel` → `UserServicing` end-to-end with `AccountViewModelTests`). **Row flipped ☐ → ☑ (Implemented + Tested): `POST /api/auth/login`** (`LiveSessionEstablisher` + `LiveSessionEstablisherTests` full quartet). **Math: Implemented 97 → 98 of 98 (all endpoints now implemented); Tested fully 66 → 74 of 98 (+8); Tested partial 25 → 18 of 98 (−7 from ◐⁴→☑, plus the ☐→☑ POST /api/auth/login removes 1 from untested not partial); Untested ☐ 7 → 6 of 98 (POST /api/auth/login now fully tested).** App test suite: 278 → 305 tests; InterlinedKit: 190 → 196; grand total across all targets: 976 → 1017. Footnote 5 resolved. - **2026-06-25 — Wave 7 update (M6 Subscriber + orgs consumed end-to-end; native OAuth blocked upstream).** `InterlinedDomain` M6 slice (`OrgService` over the Organizations endpoints with `Organization` / `OrgMember` / `OrgUser` / `OrgRole` / `OrgsPage` / `OrgMembersPage` / `OrgMappers`; `UserService` `identities()` / `organizations()` + `LinkedIdentity` / `IdentityProvider` and the new `identityLinkURL(provider:instance:)`; `MessagesService` M6 write surface — `createPost` with media / scheduled / cross-post, `scheduledPosts()`, `uploadImage` / `uploadVideo` via `ImagePrep`, all subscriber-gated via `EntitlementsService` with a live `entitlementsProvider` backstop) plus the `InterlinedPersistence` `SwiftDataOrgStore` / `SwiftDataLinkedIdentityStore`. App-layer M6 UI: Organizations (`OrganizationsRootView` + `OrganizationsListViewModel` / `OrganizationDetailViewModel` / `OrgMembersViewModel`, `.organizations` route flipped), the M6 composer extensions + read-only `ScheduledPostsRootView` (`ScheduledPostsViewModel`, `.scheduled` route flipped), and the browser-handoff `SettingsRootView` → Linked accounts pane (`LinkedAccountsView` / `LinkedAccountsViewModel`). Kit gained the additive OAuth builders (`Auth.authorize(provider:link:instance:)`, `Auth.linkedinStatus()`, `OAuthProvider`, `LinkedInStatusResponse`) from the 7.0 spike ([spike 0002](spikes/0002-oauth-identity-linking.md)). Per the Wave 1 footnote-4 rule, every M6-consumed row exercised by a tested App-layer view model this wave flips ◐⁴ → ☑. **11 rows flipped ◐⁴ → ☑**: `GET /api/user/organizations` (`UserService.organizations` → `OrganizationsListViewModel`), `POST /api/organizations` (`OrgService.create` → `OrganizationsListViewModel`), `GET /api/organizations/[id]` (`OrgService.organization(id:)` → `OrganizationDetailViewModel`), `PATCH /api/organizations/[id]` (`OrgService.update` → `OrganizationDetailViewModel`), `GET /api/organizations/[id]/members` (`OrgService.members(of:)` → `OrgMembersViewModel`), `POST /api/organizations/[id]/members` (`OrgService.addMember` → `OrgMembersViewModel`), `PUT /api/organizations/[id]/members/[userId]` (`OrgService.setMemberRole` → `OrgMembersViewModel`), `DELETE /api/organizations/[id]/members/[userId]` (`OrgService.removeMember` → `OrgMembersViewModel`), `GET /api/user/identities` (`UserService.identities` → `LinkedAccountsViewModel`), `POST /api/messages/images/upload` (`MessagesService.uploadImage` → `ComposerViewModel`; `ImagePrep` exercised), `POST /api/messages/videos/upload` (`MessagesService.uploadVideo` → `ComposerViewModel`). **Five OAuth rows flipped Implemented ☐ → ☑ but stay Tested ☐¹² — by design** (new footnote 12): `GET /api/auth/{github,mastodon,bluesky,linkedin}/authorize` and `GET /api/auth/linkedin/status` gained Kit builders (+13 tests) but native completion is **blocked upstream** ([decision 0006](decisions/0006-oauth-identity-linking-browser-handoff.md)) — the app opens `…/authorize?link=true` in the browser (reached indirectly via `UserService.identityLinkURL`), it does not send these requests, and `linkedin/status` is unconsumed. **Two org-read rows stay ◐⁴ — held back** (new footnote 13): `GET /api/organizations` (list-all variant — UI uses `UserService.organizations()` instead) and `GET /api/organizations/[id]/users` (`OrgService.users(of:)` unconsumed — roster renders from `/members`). **Re-consumed but unchanged (☑ already)**: `POST /api/messages` (already ☑ from Wave 1's M6-field builder coverage; its scheduled / cross-post / media fields are now consumed end-to-end via `ComposerViewModel` — footnote 2 resolved), `GET /api/messages/scheduled` (already ☑ from Wave 1; re-consumed read-only by `ScheduledPostsViewModel`). **Math: Implemented 92 → 97 of 98 (+5 OAuth builders; only `POST /api/auth/login`⁵ remains unimplemented); Tested fully 55 → 66 of 98 (+11); Tested partial 36 → 25 of 98 (−11); Untested 7 of 98 (unchanged — the 5 OAuth rows are now Implemented-but-Tested-☐¹², plus `POST /api/auth/login`⁵ and `POST /api/auth/register`⁶).** Footnotes 12 and 13 added; footnote 2 marked resolved. No other footnotes touched. diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index e5597cd..6a7409d 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -18,8 +18,8 @@ This page summarizes what the InterlinedList macOS app can do today and what is ## Limits worth knowing about today - **Profiles without public messages.** The current release builds a profile from the user's most recent public message. Users who have never posted publicly cannot be shown as a profile yet — you see a "no public messages yet" empty state. This is expected, not an error; it lifts when richer profile data is available. -- **Following scope on the timeline.** The timeline scope picker offers All and Mine today; a Following scope (timeline filtered to accounts you follow) is coming in a future update — the social roster and follow actions shipped with M5, but the timeline-side filter is not wired yet. -- **Watcher invites on lists.** You can rename roles or remove existing watchers on any list you own, but inviting a new user by handle is coming in a future update — the backend lookup endpoint the share sheet needs is not yet available. +- **Following scope on the timeline.** The timeline scope picker now offers All, Mine, and Following. Following (a timeline filtered to accounts you follow) is UI-wired but not yet data-backed: until the backend ships a following-feed endpoint, selecting Following shows a "Following feed coming soon" empty state rather than posts. All and Mine work today; Following becomes live once the endpoint lands. +- **Inviting watchers to a list.** On any list you own you can invite a new watcher by their `@handle`: the Add Watcher sheet looks the person up, shows the matched user, and adds them with the role you choose (Viewer, Editor, or Owner). You can still change roles or remove existing watchers at any time. - **Connections graph layout.** The list-connections graph currently uses a stable radial arrangement. An animated force-directed layout will land in a follow-up. - **GitHub-backed list refresh is manual.** Use the toolbar Refresh button on a GitHub-sourced list to pull the latest rows. Automatic background refresh will arrive in a later update. - **"Save to my lists" copies metadata only.** From a public list, the Save action creates an owned list with the same title, description, and schema, but does not yet copy the rows. Row-level cloning lands when the backend ships its clone endpoint. @@ -31,10 +31,10 @@ This page summarizes what the InterlinedList macOS app can do today and what is - **Follow-button initial state.** When you open another user's profile the **Follow** button needs a round-trip to learn whether you already follow them; for a moment after opening the profile the button stays hidden. This is intentional — showing "Follow" against a user you already follow would be a wrong default. - **Notification deep-linking is minimal in v1.** Clicking a system notification brings InterlinedList forward; routing to the specific message, list, or profile each notification refers to lands in a follow-up. Open the in-app Notifications tab to navigate to the related content. - **Linking other accounts happens in your browser.** From **Settings > Linked accounts**, choosing **Link account** for GitHub, Mastodon, Bluesky, or LinkedIn opens the linking page in your default browser, where you sign in and approve the connection on the InterlinedList website. The app does not complete the link in-app yet — native in-app linking is coming in a future update once the service supports a callback the app can handle. After you finish in the browser, return to the Linked accounts pane and it refreshes to show the new connection. (For Mastodon you are asked for your instance domain first.) -- **Cross-posting has no per-platform result summary yet.** When you turn on cross-posting to Mastodon, Bluesky, or LinkedIn while composing, the post is sent with those targets, but the app cannot yet show a per-platform "posted ✓ / failed" sheet after publishing — the service does not return that breakdown today. A status summary lands in a future update once it does. -- **Per-platform cross-post readiness is only known for LinkedIn.** The composer can tell whether LinkedIn cross-posting is configured and reflect that in the toggle, but it cannot yet detect readiness for Bluesky or Mastodon (per instance) before you post — so an unconfigured platform is only discovered when the post is sent. Pre-flight readiness for Bluesky and Mastodon is coming in a future update. -- **Scheduled posts are read-only for now.** The **Scheduled** sidebar section lists posts you have scheduled for later, but you cannot cancel or reschedule one from the app yet — editing a scheduled post before it publishes is coming in a future update once the service exposes cancel / reschedule. -- **Adding an organization member uses a user id, not a handle.** On an organization you own, you can add a member by entering their user id and pick a role, and you can change roles or remove existing members. Searching for a person by their `@handle` to add them is coming in a future update — it needs the same handle-lookup endpoint the list-sharing invite flow is waiting on. +- **Cross-posting shows a per-platform result summary.** When you turn on cross-posting to Mastodon, Bluesky, or LinkedIn while composing, publishing the post opens a per-platform result summary sheet. Each target is shown as posted (with a link to the published message), pending, or failed, and failures include a human-readable reason — for example "Rate limited — try again later.", "Auth expired — re-link your account.", or "Blocked by content policy." Close the summary with **Done**. +- **Cross-post readiness is checked before you post.** When you enable a cross-post toggle in the composer, the app pre-flights whether that platform is configured — for Bluesky and per-instance Mastodon as well as LinkedIn. If a toggled platform isn't set up, the composer turns the toggle back off and shows an inline hint, so you find out before publishing rather than after. +- **You can cancel or reschedule scheduled posts.** The **Scheduled** sidebar section lists posts you have queued for later. Right-click a row to **Reschedule…** it (pick a new publish date and time) or **Cancel Post** (which deletes the scheduled post). Both actions update the list immediately and roll back if the change can't be saved. +- **Adding an organization member by @handle or user id.** On an organization you own, you can add a member by their `@handle` — the app looks the person up and shows a confirmation row before adding them with the role you pick — or by entering their user id directly. You can also change roles or remove existing members. ## Related pages diff --git a/feature-blockages.md b/feature-blockages.md new file mode 100644 index 0000000..40492bf --- /dev/null +++ b/feature-blockages.md @@ -0,0 +1,33 @@ +# Backend Blockages — macOS app parity work + +**For:** the InterlinedList backend / API team · **From:** macOS native app · **Date:** 2026-07-18 + +> **Canonical tracker:** [`blocker-prompts.md`](blocker-prompts.md). The parity asks below were **folded into it** on 2026-07-18 under the `P#` scheme (with paste-ready prompts) — this file is now just a parity-focused index into that tracker. Everything was verified against current code, not memory. + +## New parity asks (now tracked in `blocker-prompts.md`) + +| Parity ask | Impact | Tracker ID | +| --- | --- | --- | +| Following / home feed endpoint (client UI wired, short-circuits to empty) | **High** | **P1-G** | +| GitHub issue create/comment + labels/assignees (largest gap; extends P3-C) | **High** | **P1-H** | +| Markdown export format / per-item export (client renders MD itself for now) | Medium | **P2-F** | +| Schema DSL `select`/`markdown` token spec (client shipped both; confirm tokens) | Medium | **P2-G** | +| Link-preview `fetchStatus` value docs | Low | **P3-F** | +| List "save to my lists" clone-with-rows | Low | **P3-G** | +| Message edit verb `PATCH` (docs) vs `PUT` (client) | Low | **P3-H** | + +Already tracked and still open, relevant to parity: **P1-E** (native OAuth link callback — the one OAuth blocker), **P2-C** (notification `routePath` for deep-linking), **P2-D** (upload limits), **P2-E** (Privacy/Support pages for App Store). + +**Hand-off priority: P1-G and P1-H unlock the most user-visible parity.** + +## Corrections — thought blocked, actually already shipped + +The first-draft blockages list (and the older project memory) wrongly flagged these as backend-gated. **They are shipped and wired** — verified in code, and `blocker-prompts.md` already marks the enabling endpoints resolved (NW-1…NW-6). Do **not** spend backend time here. + +| Was flagged | Reality | Evidence | +| --- | --- | --- | +| Scheduled post cancel/reschedule | **Done** (P1-C / NW-3) | `ScheduledPostsViewModel.cancel()`/`.reschedule()` + UI; `MessagesService.cancelScheduled`/`reschedule` | +| Bluesky/Mastodon cross-post readiness | **Done** (P1-D / NW-4) | `ComposerViewModel.blueskyNotConfigured`/`mastodonNotConfigured` | +| Watcher-invite-by-handle | **Done** (P1-A / NW-6) | `WatchersViewModel.lookupAndAdd(handle:)` → `UserService.lookupUser` | +| Org-member-add-by-handle | **Done** (P1-A) | `OrgMembersViewModel.addMemberByHandle` | +| Cross-post per-platform result summary | **Done** (P1-B / NW-2) | `CrossPostResultsSheet` wired in `ComposerWindowView` | diff --git a/feature-gaps.md b/feature-gaps.md new file mode 100644 index 0000000..84d01e2 --- /dev/null +++ b/feature-gaps.md @@ -0,0 +1,113 @@ +# Feature Parity Gaps — macOS app vs. interlinedlist.com + +> **⚠️ Superseded (2026-07-31).** This file is now historical. The live web API has grown well beyond the surface this doc reviewed (Direct Messages, Share Links, Moderation, List Folders, Search, GitHub issue writes, X/Twitter, billing, push, …). The current, live-verified gap list and implementation plan live in **[`the-gaps.md`](the-gaps.md)** — use that as the working document. Kept here for provenance. + +**Reviewed:** 2026-07-18 · **Branch:** `dev` · **Basis:** [interlinedlist.com/features](https://interlinedlist.com/features) cross-referenced against the App target, `InterlinedDomain`, `InterlinedKit`, and `InterlinedPersistence`. + +> **2026-07-18 implementation update.** Most of the client-closable gaps in §1 were **built this session** (schema `select`/`markdown`, link previews, document templates, and the Markdown-export engine). The build is green: **App 375 tests / 0 failures, InterlinedDomain 475 / 0** (Kit 224, Persistence 120 unchanged). Backend-blocked items are tracked in **[`feature-blockages.md`](feature-blockages.md)**, which is reconciled against the canonical **[`blocker-prompts.md`](blocker-prompts.md)**. Several items the first draft called "blocked" turned out to be **already shipped** — see §2b. + +## TL;DR + +The native app is **at or very near full parity**. Every documented API endpoint is implemented (98/98 per [`docs/api-coverage.md`](docs/api-coverage.md)), and the NW-1…NW-6 backend items are done (per `blocker-prompts.md`). What remained were a handful of surface features; the client-closable ones are now largely done, and the genuine gaps are backend-gated (following feed, GitHub issue writes, OAuth callback). + +### Parity scorecard + +| Site feature area | Status | +| --- | --- | +| Compose: Markdown, images **+ video**, scheduling, threading, digs/reactions | ✅ Shipped | +| Cross-post Mastodon/Bluesky/LinkedIn, per-message targets, **result summary**, **pre-flight readiness** | ✅ Shipped | +| Structured lists: CRUD, nesting, connections graph, watchers (**invite by @handle**) | ✅ Shipped | +| Schema DSL field types (`text, number, date, select, boolean, url, markdown`) | ✅ **`select` + `markdown` added this session** (kept `email`) | +| List row views: cards / **grid** / ERD | ✅ Cards + **grid (real Table)** shipped; ERD = scope TBD | +| Documents: Markdown editor, folders, image upload, public/private, offline sync | ✅ Shipped | +| Document **templates** | ✅ **Added this session** (Blank / Meeting Notes / Daily Log / PRD) | +| Rich link previews on posts | ✅ **Added this session** (server metadata → preview card) | +| Exports: **Markdown** for lists / documents / threads | ✅ Engine + **My Lists → Markdown** UI shipped; per-doc/thread buttons = follow-up | +| Exports: CSV | ✅ Shipped | +| Scheduled post edit/cancel | ✅ Shipped (was mis-listed as blocked — see §2b) | +| Organizations & roles (**add member by @handle**) | ✅ Shipped | +| Feed filtering: all / mine / following | ⚠️ All/Mine shipped; **Following UI-wired but empty** — backend feed endpoint pending (§2) | +| GitHub sync: issue create/comment, labels/assignees | ❌ Backend-gated (§2 / `feature-blockages.md` NB-2) | +| Native OAuth account linking | ❌ Backend-gated (§2 / `blocker-prompts.md` P1-E) | +| AI writing assist | "Coming Soon" on site too — not a gap | + +--- + +## 1. Client-closable gaps + +**Project constraint for all work here:** the App target is **SwiftUI-only — no AppKit / `NSViewRepresentable`** without asking; follow the MVVM + `InterlinedDomain` service seam and add BDD-style tests (`AppTests/` conventions). + +### 1.1 — Schema DSL: `select` + `markdown` field types ✅ DONE (this session) + +`SchemaFieldType` gained `.select` (ordered options via `SchemaField.enumValues`, DSL `Field:select(a|b|c)`) and `.markdown` (long text, Textual preview in `RowInspectorView`). Editor + row cells + DSL parser/serializer updated; 25 new tests. **Backend confirmation needed** on the exact `select` token/delimiter and `email` acceptance — see `feature-blockages.md` NB-4. + +### 1.2 — List row views: grid, then ERD ✅ GRID DONE (this session); ERD scoped separately + +`ListRowsView.tableMode` (owned lists) now renders a **real SwiftUI `Table`** with one typed column per schema field via `TableColumnForEach` (valid at the macOS 15 target; the stale 14.4 fallback comment is removed). Pagination is a Load-More footer (Table has no per-row appearance hook); cards mode keeps scroll-to-load. Columns come from the schema, falling back to the sorted union of row keys. + +**ERD remains open** — before building, confirm what "ERD" means in the web app (schema field graph vs. the existing list-to-list connection graph) so it's scoped correctly. The public `ListDetailView` (read-only browse) still shows cards only; a grid there is a smaller follow-up. + +### 1.3 — Markdown export ✅ DONE (this session) + +`MarkdownExporter` (`InterlinedDomain`) renders documents, threads, and lists-as-tables (pipe/newline-escaped), 15 tests. The Export sheet now offers **"Export My Lists as Markdown"** — `ExportViewModel` paginates owned lists + rows and renders them into a `MarkdownFileDocument` (`.md`) via a second `fileExporter`; 4 view-model tests. The `/api/exports/*` endpoints are CSV-only, so this composes client-side (server-side ask = `blocker-prompts.md` P2-F). + +**Follow-up (engine already supports it):** per-item "Export as Markdown" affordances — document editor toolbar (`markdown(for:)`) and message-thread menu (`markdown(forThreadRoot:replies:)`). + +### 1.4 — Document templates ✅ DONE (this session) + +`DocumentTemplate.builtIn` catalog (Blank / Meeting Notes / Daily Log / PRD) + "New from Template…" command (⇧⌥⌘N) and picker sheet, seeding `DocumentBody.markdown` through the existing create path; 12 tests. Client-side (no templates endpoint). + +### 1.5 — Rich link previews ✅ DONE (this session) + +**Was not blocked** — the server already returned `linkMetadata`. Added `Message.linkPreviews` + mapper (drops unparseable URLs) + a tappable `LinkPreviewCardView` in the timeline; 16 tests. Render gate is forward-compatible pending `fetchStatus` docs (`feature-blockages.md` NB-5). + +--- + +## 2. Genuinely backend-blocked (cannot close from the client) + +Full asks + prompts in **[`feature-blockages.md`](feature-blockages.md)** (reconciled with `blocker-prompts.md`). Summary: + +- **Following feed** (NB-1 · HIGH) — `TimelineScope.following` is UI-wired but `MessagesService.timeline` short-circuits to empty; no endpoint exists. +- **GitHub issue create/comment + labels/assignees** (NB-2 · HIGH) — the largest genuine gap. `blocker-prompts.md` P3-C tracks only refresh *metadata*; issue writes are net-new. +- **Native OAuth account linking** (`blocker-prompts.md` P1-E) — linking opens the browser; no native callback/scheme or bearer link endpoint. +- **Markdown export format** (NB-3 · med) — client works around it; server format negotiation would be more efficient. +- **List "save to my lists" row cloning** (NB-6 · low) — copies metadata+schema only. + +## 2b. Corrections — thought blocked, actually already shipped + +The first draft (and the older project memory) listed these as blocked. They are **done** — verified in code. The user-facing `docs/user/feature-status.md` still described some as pending; that staleness is being corrected in a parallel docs pass. + +| Feature | Evidence it's shipped | +| --- | --- | +| Scheduled post cancel/reschedule | `ScheduledPostsViewModel.cancel()`/`reschedule()` + UI (P1-C / NW-3) | +| Cross-post pre-flight readiness (Bluesky/Mastodon) | `ComposerViewModel.blueskyNotConfigured`/`mastodonNotConfigured` (P1-D / NW-4) | +| Watcher invite by @handle | `WatchersViewModel.lookupAndAdd` → `UserService.lookupUser` (P1-A / NW-6) | +| Org member add by @handle | `OrgMembersViewModel.addMemberByHandle` (P1-A) | +| Cross-post per-platform result summary | `CrossPostResultsSheet` wired in `ComposerWindowView` (P1-B / NW-2) | + +--- + +## 3. Already at parity (do not re-implement) + +Composer (Markdown, image+video, scheduling incl. cancel/reschedule, per-message cross-post + result sheet + readiness); digs/reposts/threads; Documents (editor, folders, image upload, public/private, offline sync, templates); link previews; Social (follow/unfollow, requests, mutuals, notifications, dock badge); Organizations (CRUD, members incl. by-handle, roles); Lists (CRUD, schema DSL incl. select/markdown, nesting, connections graph, watchers incl. invite-by-handle); CSV export; feed All/Mine. + +--- + +## 4. Ship (M7) — gates release, not parity + +- **Sparkle** — `SparkleController` + SPM dep in place; verify update-check call, `SUFeedURL`, `SUPublicEDKey`, key generation. +- **Appcast hosting** — needs distribution infra on interlinedlist.com. +- **Notarization** — `scripts/notarize.sh`/`package-pkg.sh` need Developer ID certs. +- Target: notarized **`.pkg`** (closed-source private repo; no `LICENSE`). +- App Store extras tracked in `blocker-prompts.md`: **P2-E** (Privacy/Support pages), **P2-D** (limits endpoint). + +--- + +## Suggested order of remaining work + +1. **Backend:** hand `blocker-prompts.md` **P1-G** (following feed) and **P1-H** (GitHub issue writes) to the API team — they unlock the most parity. +2. **ERD view** — confirm what "ERD" means in the web app, then build (grid is done). +3. **Follow-ups:** per-document / per-thread "Export as Markdown" buttons (engine ready); grid on the public `ListDetailView`. +4. **§4 ship** — Sparkle finalization + notarization. + +*Done this session: §1.1 schema select/markdown, §1.2 grid, §1.3 Markdown export (engine + UI), §1.4 templates, §1.5 link previews.* diff --git a/the-gaps.md b/the-gaps.md new file mode 100644 index 0000000..049da1e --- /dev/null +++ b/the-gaps.md @@ -0,0 +1,318 @@ +# The Gaps — Feature-Parity Backlog & Plan (macOS native ↔ interlinedlist.com) + +**This is the working document** for what to build next to reach parity with the live web app. It consolidates and supersedes the parity content previously split across `feature-gaps.md` (2026-07-18) and `docs/api-coverage.md` (verified 2026-06-11). Those two files remain as historical/detailed references — `api-coverage.md` is still the place for the per-endpoint ☑/◐ test matrix once it is re-baselined (see §8) — but **this file is the source of truth for the gap list and the plan.** + +- **Reviewed:** 2026-07-31 · **Branch:** `feature/web-parity-batch-2026-07` +- **Basis (should-have):** live [`GET /api/openapi.json`](https://interlinedlist.com/api/openapi.json) (~150 endpoints) + `/help/api/*` section docs. +- **Basis (has):** grep + `Explore` over `Packages/` and `App/` on this branch. +- **Basis (live truth):** **logged into the production API** with the `.env` test account `messenger@interlinedlist.com` (`customerStatus: "subscriber"`, email-verified) via `POST /api/auth/sync-token` and probed endpoints read-only. Findings are in §2 and the appendix. + +## Contents +- §1 — **The gap list** (prioritized, what to build) +- §2 — Live verification evidence (2026-07-31) +- §3 — Already at parity (do **not** re-implement) +- §4 — Architecture & conventions (the build seam) +- §5 — The plan, wave by wave (per-gap specs) +- §6 — API-drift / migration pass (corrected against live) +- §7 — Ship gating (M7) — release, not parity +- §8 — Coverage tracking (re-baseline `api-coverage.md`) +- §9 — Sequencing & rationale +- §10 — Open questions / spikes (resolved vs. remaining) +- Appendix — raw live probe log + +--- + +## 1. The gap list + +The prior docs declared "near-full parity, 98/98 endpoints." That was true **for the API as of 2026-06-11.** The live API has since grown to ~150 endpoints across whole new feature areas the app has never implemented. Real coverage today is **~98 of ~150.** + +Legend — **Status:** ❌ absent · ◑ partial · **Tier:** free / **Sub** (subscriber-gated server-side) · **Size:** S / M / L · **Backend:** ✅ live & verified this session · ⚠️ live but constrained · ❔ unverified. + +| # | Gap | Status | Tier | Priority | Size | Backend | Notes from live probe (§2) | +|---|---|---|---|---|---|---|---| +| **G1** | **Direct Messages** — 1:1 DMs (mutual followers), inbox/sent/deleted folders, threads, read state, unread badge, ≤8 image attachments, trash/restore | ❌ | free | **P0** | L | ✅ | `/api/dm` 200; `/api/dm/recipients` returns eligible users; `/api/dm/unread-count` 200. Actively promoted by the product. | +| **G2** | **Moderation** — block/unblock, mute/unmute, report user, report message | ❌ | free | **P0** | M | ✅ | `/api/user/blocks` & `/api/user/mutes` 200 (paginated). | +| **G3** | **Share Links & Collaborators** — tokenized viewer/editor/admin links + per-person grants for **lists & documents**, claim/revoke, read-only shared data, "shared-with-me" | ❌ | **Sub** (create) | **P1** | L | ✅ | `/api/lists/{id}/share-links`, `/api/documents/{id}/share-links`, `…/collaborators` all 200; **`/api/lists/watching` returns real "shared-with-me" data.** | +| **G4** | **GitHub issue integration** — list repos, list/create/edit issues, comment, labels, assignees, next-issue-number, "create issue from message/list" | ❌ | Sub | **P1** | M | ✅ | `/api/github/repos` → 400 "GitHub account not linked" — endpoint live, needs OAuth link (already have native linking, G-resolved). | +| **G5** | **Search** — messages, lists, documents (server-side) | ❌ | free | **P1** | M | ✅ | `GET /api/{lists,documents}/search` 200; **messages search is `GET /api/messages/search?q=` (POST → 405).** | +| **G6** | **List Folders** — hierarchical folders for lists (distinct from doc folders), attach/detach, cycle-safe move | ❌ | **Sub** (create) | **P1** | M | ✅ | `/api/folders` 200 `{folders:[]}`; **lists already carry a `folderId` field** → clean hook. | +| **G7** | **X / Twitter cross-posting** — OAuth link + per-message target + readiness, extending the existing Mastodon/Bluesky/LinkedIn composer | ❌ | Sub | **P2** | S | ✅ | `/api/auth/twitter/status` → `{configured:true, redirectUri:…/twitter/callback}`. | +| ~~G8~~ | ~~In-app billing~~ — **OUT OF SCOPE (2026-07-31): billing is managed in the online app.** Not a native gap. (The app still *reads* `customerStatus` to gate subscriber features; it never sells or manages subscriptions.) | — | — | — | — | — | Dropped from the plan. Stripe routes were also 404/not-deployed. | +| **G9** | **Push notifications (APNs)** — register/unregister device token for native pushes (replaces tray polling) | ❌ | free | **P2** | M | ✅ | `POST /api/push/register` → 400 "token is required" (route live). `unregister` is not POST (405) — likely DELETE, confirm. | +| **G10** | **Multi-account switching** — list accounts, switch active, add/remove | ❌ | free | **P3** | M | ⚠️ | **`/api/auth/accounts` → 401 with Bearer — session-cookie-only.** Native bearer clients can't switch server-side without a session or a bearer variant. Spike first. | +| **G11a** | **LinkedIn personal posting targets** — choose target(s) in composer | ❌ | Sub | **P3** | S | ✅ | `/api/linkedin/{targets,posting-targets}` 200 with a real personal target. | +| **G11b** | **LinkedIn org pages** — org-scoped posting / `orgs/{id}/linkedin-page` | ❌ | Sub | **P4** | M | ⚠️ | `orgScopesEnabled:false`; `…/linkedin-page` → 404. Not deployed for this tenant — treat as upstream-blocked. | +| **G12** | **Server-side document templates** — `templates`, `from-template`, `tree` (app has *client-side* templates only) | ◑ | Sub | **P3** | S | ✅ | `/api/documents/templates` returns real seeded templates; `/api/documents/tree` returns one-call sidebar payload. | +| **G13** | **Document presence / live cursors** — real-time co-editing heartbeat | ❌ | Sub | **P4** | M | ❔ | Not probed. Highest complexity, lowest parity urgency — confirm demand before building. | +| **G14** | **Utility surfaces** — optional `/api/limits`-driven composer validation (message length / media limits); weather/geolocation/image-proxy helpers | ❌ | free | **P4** | S | ✅ | Optional polish, not billing. `/api/limits` shape verified (§2). | + +### Still-open items carried forward from the old `feature-gaps.md` +- **Following feed** (old NB-1) — **re-verified 2026-07-31: still backend-blocked.** `GET /api/messages` ignores `feed`/`scope`/`following`/`filter` params (all return the same 216-total "all" feed), and `POST /api/user/update {viewingPreference}` returns 405. No working client-side following-feed mechanism exists; the app's short-circuit stays. Needs a backend feed endpoint (or a documented `viewingPreference` write path). **Backend-blocked.** +- **Per-document / per-thread "Export as Markdown"** buttons — the `MarkdownExporter` engine already supports them; only the toolbar/menu affordances are missing. **P3, S.** +- **ERD list view** — the third documented list view mode (cards ✅, grid ✅, ERD ❌). Confirm whether "ERD" means the schema-field graph or the existing list-to-list connection graph before building. **P3.** +- **Public grid on read-only `ListDetailView`** — owned lists render a real `Table`; the public browse view still shows cards only. **P4, S.** + +--- + +## 1b. Implementation progress (2026-07-31) + +Build has started, backend-first (each gap's layers: Kit → Domain → Persistence → App). + +| Gap | Kit + DTOs | Domain + tests | Persistence | App UI | Notes | +|---|---|---|---|---|---| +| **G5 Search** | ✅ | ✅ 17 tests | n/a | ⏳ agent | `Search` endpoint + `SearchService`; reuses `Message`/`ListSummary`/`Document` mappers | +| **G2 Moderation** | ✅ | ✅ 19 tests | n/a | ⏳ agent | `Moderation` endpoint + `ModerationService` (block/mute/report/isBlocking); fire-and-forget via `sendVoid` | +| **G6 List Folders** | ✅ | ✅ 17 tests | ⏳ | ✅ agent | `ListFolders` endpoint + `ListFoldersService` (subscriber gate + cycle-safe tree builder); sidebar folder tree wired | +| **G1 Direct Messages** | ✅ | ✅ 20 tests | ⏭ deferred | ✅ agent | Wire shape captured via one authorized recon DM (trashed). Full UI: folder/conversation/thread panes, `threadUpdates` polling, composer + recipient picker, profile "Message" action, `UnreadBadgeAggregator` (DM + notifications sum). Persistence needs a domain-side store seam (follow-up). | +| **G3 Share Links** | ✅ | ✅ 18 tests | n/a | ✅ agent | Share Links panel (create/list/revoke + role picker + subscriber upsell) on Lists + Documents toolbars; `ResolveShareView` landing via `ShareURLParser` (`interlinedlist://` + pasted URLs) with claim. Collaborator per-person grants = follow-up. | +| **D2 Public profile** | ✅ | ✅ | ✅ (auto) | `User.publicProfile` (`GET /api/users/{username}`) + `UserProfile.init(from:)`; `SocialService.profile` now prefers the real endpoint (rich bio/counts/joinedAt/isPrivate), decision-0002 message-fallback retained only on 404. Existing profile UI shows the richer data with no view changes. +8 tests. | +| **G7 X/Twitter cross-post** | ✅ | ✅ | ✅ | Additive mirror of Bluesky/LinkedIn: `.twitter` provider + `twitterStatus()` + `crossPostToTwitter` on `CreateMessageRequest` (threaded through `MessagesService.createPost` + composer X toggle). +12 tests. **⚠️ field name `crossPostToTwitter` is pattern-matched, UNVERIFIED** (test account has no linked X identity) — one-file fix if it's `crossPostToX`. | +| **G4 GitHub** | ⏸ | — | — | — | Endpoints live but 400 "GitHub account not linked" — shapes can't be verified without linking the test account's GitHub identity (browser OAuth). Buildable from GitHub's stable public shapes + flag, but deferred to keep the verify-first bar. | +| ~~G8 Billing~~ | ❌ OUT OF SCOPE | — | — | — | Billing is managed in the online app (owner decision 2026-07-31). Dropped from the parity plan. (Stripe routes were also 404/not-deployed.) | +| **G9 Push** | ⏸ | — | — | — | `POST /api/push/register` live (400 "token is required"), but needs an APNs entitlement + provisioning under the notarized `.pkg` model (spike S2) before it's useful. | +| **G12 Templates** | ✅ | ✅ 11 tests | n/a | ✅ agent | Server templates end-to-end: `Documents.templates()`/`createFromTemplate()`/`seedDefaultTemplates()` + `DocumentTemplatesService`; two-section "New from Template" picker (Built-in + "Your templates") with create+reload + seed-defaults. `from-template` body `{templateDocumentId}` verified via create+delete. `/api/documents/tree` deferred (sidebar-hydration optimization). | +| G10, G11b, G13, G14 | — | — | — | — | not started (lower priority / deferred) | + +**Live-API blockers found while building (documented like the earlier `/api/orgs`, `/api/users/current` 404s):** the Stripe billing routes and — from the first review — org LinkedIn pages are **not deployed** on the live server despite appearing in the OpenAPI spec. GitHub issue routes are deployed but require a linked identity to exercise. + +**Test delta (all green):** InterlinedKit 224 → **250** (+26); InterlinedDomain 475 → **522** (+47); App target 379 → **452** (+73: Search/Moderation/ListFolders/DirectMessages UI). **+146 new passing tests, 0 regressions.** (`swift test` for the packages; `xcodebuild test` with `CODE_SIGNING_ALLOWED=NO` for the App target.) + +**Follow-ups noted during the build:** (1) G1 SwiftData cache needs a store port added to `DirectMessagesService` first; (2) confirm `threadUpdates` `since`-token semantics (currently newest message id); (3) project-level test-target code-signing so a plain `xcodebuild test` passes. + +**Wire-shape verification note (G1):** the DM object shape was confirmed live on 2026-07-31 by sending one clearly-labeled recon DM from the test account to the owner's account and reading it back, then trashing it — `{ id, pairKey, senderId, recipientId, body, imageUrls[], createdAt, readAt?, sender/recipient:UserSummary, preview }`; `POST /api/dm` → `{message:…}`; thread → `{items, olderCursor, isMutual, isBlocked, otherUser}`. + +**Verified-shape build order rationale:** features are being built in order of wire-shape certainty. Search reuses existing DTOs (zero risk); Moderation/List-Folders envelopes were verified live. G1 DMs and G3 Sharing are deferred until their object shapes can be confirmed against live data, to avoid shipping an unverified decode path. + +--- + +## 2. Live verification evidence (2026-07-31) + +Logged in as `messenger` (subscriber) and probed read-only. This is why the gap list above is trustworthy and corrects two mistakes in the first draft. + +**Confirmed live & Bearer-reachable (feature backends exist):** `/api/dm*`, `/api/user/blocks`, `/api/user/mutes`, `/api/lists/{id}/share-links`, `/api/documents/{id}/share-links`, `/api/documents/{id}/collaborators`, `/api/lists/watching`, `/api/folders`, `/api/lists/search`, `/api/documents/search`, `/api/messages/search` (GET), `/api/documents/templates`, `/api/documents/tree`, `/api/linkedin/targets`, `/api/linkedin/posting-targets`, `/api/github/repos` (needs link), `/api/push/register`, `/api/users/{username}` (public profile), `/api/limits`. + +**`/api/limits` exact shape** (drives G8/G14 and composer validation): +```json +{ "media": { "image": { "maxBytes": 1468006, "maxPixels": 1200, + "acceptedFormats": ["jpeg","png","gif","webp"] }, + "video": { "maxBytes": 3145728, "acceptedFormats": ["mp4","mov"] } }, + "message": { "maxContentLength": 5000 } } +``` + +**Corrections to the earlier draft (verified against live):** +| Claim in first draft | Live reality | Consequence | +|---|---|---| +| "Orgs drifted to `/api/orgs`" | `/api/orgs` → **404 HTML shell**; `/api/organizations` → **200** | App's current path is **correct**. Dropped from drift list. | +| "User endpoints drifted to `/api/users/current`" | `/api/users/current` → **404 `user_not_found`** (parsed as username) | App's `/api/user` is **correct**. Dropped from drift list. | +| "`GET /api/users/{username}` now exists" | `/api/users/messenger` → **200 real profile** | ✅ Valid → adopt it (D2), replace the decision-0002 fallback. | +| Multi-account is a straightforward gap | `/api/auth/accounts` → **401 with Bearer** | Session-only → G10 needs a spike, not just a builder. | +| LinkedIn org pages are buildable | `orgScopesEnabled:false`, `…/linkedin-page` 404 | Org pages (G11b) are upstream-blocked; personal targets (G11a) are fine. | + +--- + +## 3. Already at parity — do **not** re-implement + +The app implements **98 endpoints** across the 2026-06-11 surface (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 2). Test coverage per the last `api-coverage.md` update: **74/98 fully tested (☑), 18 partial (◐), 6 untested**. Feature areas already shipped: + +- **Composer:** Markdown, image **+ video** upload, scheduling (incl. cancel/reschedule), threading, digs/reactions; cross-post **Mastodon / Bluesky / LinkedIn** with per-message targets, a result-summary sheet, and pre-flight readiness. +- **Lists:** CRUD, schema DSL (`text, number, date, select, boolean, url, markdown`, kept `email`), nesting, connections graph, watchers (invite by @handle), **cards + real-`Table` grid** views. *(ERD view still open — §1.)* +- **Documents:** Markdown editor, folders, image upload, public/private, offline delta sync, **client-side** templates, rich link previews. +- **Social:** follow/unfollow, requests, mutuals, notifications, dock badge. +- **Organizations:** CRUD, members (incl. by @handle), roles. +- **Exports:** CSV (messages/lists/rows/follows) + **Markdown** export for lists (engine also covers docs/threads — buttons pending, §1). +- **Feed:** All / Mine. *(Following pending — §1.)* + +### Resolved since the old docs (the old files still mislabel these as blocked) +| Item | Old label | Reality on this branch | +|---|---|---| +| **Native OAuth account linking** | "backend-gated / browser-handoff only" | ✅ **Built & tested.** `Auth.linkIdentity` → `POST /api/auth/{provider}/link`, `UserService.linkIdentityNative`, `interlinedlist://oauth/callback` scheme, `ASWebAuthenticationSession`. Directly unblocks G4/G7 linking. | +| **GitHub issue writes** | "largest genuine gap, backend-blocked (NB-2)" | Backend **live** → buildable gap **G4**. | +| **Public profile read** | "no endpoint; decision-0002 fallback" | Endpoint **live** → migration **D2**. | +| Scheduled cancel/reschedule, cross-post readiness, watcher/org add-by-handle, cross-post result sheet | listed "blocked" in an even older draft | Already shipped (verified in code by the prior review). | + +--- + +## 4. Architecture & conventions — the build seam + +Every gap follows the same layered seam the codebase already uses, so the work is mechanical: + +1. **`InterlinedKit`** — one `Request` builder file per endpoint group + `Codable` DTOs; add `…EndpointTests` (builder shape). Send **Bearer** (sync-token); `LiveSessionEstablisher` handles the session-cookie fallback. +2. **`InterlinedDomain`** — a `…Service` with domain models + DTO→model mappers; **gate subscriber-only create-paths through `EntitlementsService`** (`customerStatus`) and surface server `403`s as an upsell rather than an error. BDD-named service tests: happy / invalid / failure / empty. +3. **`InterlinedPersistence`** — `…Record` + `SwiftData…Store` for anything wanting offline cache / optimistic UI (DMs, folders, share grants, unread counts). Store tests. +4. **`App`** — SwiftUI MVVM view models + views + sidebar/menu wiring. **SwiftUI-only — no AppKit / `NSViewRepresentable` without asking.** `AppTests/` view-model tests. +5. **Docs** — add rows to `docs/api-coverage.md` (§8) and update `docs/user/feature-status.md`. + +--- + +## 5. The plan — waves + +Each wave is independently shippable, ordered by value ÷ effort and dependency. + +### Wave 1 — Social safety & messaging (P0) + +**G1 · Direct Messages** *(free, self-contained, highest daily value)* +- **Kit** `DirectMessagesEndpoint.swift`: `list(folder:cursor:)` `GET /api/dm` (folders inbox/sent/deleted, cursor paginated — confirmed); `send(recipientId:body:imageUrls:)` `POST /api/dm` (≤8 images); `thread(username:)`, `threadUpdates(username:since:)` (polling), `unreadCount()`, `recipients()`, `get(id:)`, `markRead(id:)`, `trash(id:)`, `restore(id:)`, `uploadImage(...)`. DTOs: `DirectMessageDTO`, `DMThreadDTO`, `DMFolder`, `DMRecipientDTO`. +- **Domain** `DirectMessagesService`: eligibility (self/blocked/non-mutual → typed error), thread hydration, unread rollup, per-side soft-delete. +- **Persistence** `SwiftDataDMStore` (thread cache + outbox) + unread-count cache for the badge. +- **App** `DirectMessagesRootView` (folder switcher → conversation list → thread) + `DMThreadViewModel` polling `…/updates`; composer with image attachments; unread badge into the existing dock/notification coordinator; "Message" action on profile headers gated to mutual followers (`recipients()`). +- **Tests** eligibility matrix, read-state transitions, independent per-side trash/restore, unread rollup. **L.** + +**G2 · Moderation** *(safety table-stakes; interlocks with G1 eligibility)* +- **Kit** `ModerationEndpoint.swift`: `blocks(limit:offset:)`, `isBlocking/block/unblock(username:)`, the `mute` trio, `reportUser(username:reason:detail:)`, `reportMessage(id:reason:detail:)`. `ReportReason` enum (`harassment|spam|misinformation|inappropriate|other`). +- **Domain** `ModerationService` exposing `isBlocked`/`isMuted` so timeline / thread / DM view models filter locally + reconcile. +- **App** overflow-menu block/mute/report (reason sheet) on profiles, timeline rows, DM threads; **Settings → Blocked & Muted** management pane. +- **Tests** block hides author + blocks DM eligibility, report validation, idempotent block/unblock. **M.** + +### Wave 2 — Collaboration (P1) + +**G3 · Share Links & Collaborators** *(subscriber create-gate; lists + documents)* +- **Kit** extend `ListsEndpoint` + `DocumentsEndpoint`: `shareLinks`, `createShareLink(role:expiresAt:)`, `revokeShareLink(token:)`, `resolveShared(token:)`, `claimShared(token:)`; lists add `sharedData(token:)` and adopt `listsWatching()` `GET /api/lists/watching`; documents add the `collaborators` CRUD quartet + `searchCollaboratorUsers`. DTOs: `ShareLinkDTO{token,url,role,expiresAt}`, `ShareRole` enum (`watcher`/`collaborator`/`manager` ↔ Viewer/Editor/Admin), `ResolvedShareDTO{role,canClaim,needsAuth,resource}`. +- **Domain** `SharingService`: create/list/revoke (subscriber-gated → upsell on 403), resolve+claim (free recipients), collaborator roles; unify existing list "watchers" with the new role model (see spike S3). +- **App** reusable **Share sheet** (People tab: add by @handle + role + remove · Links tab: create with role + optional expiry, copy, revoke); a shared-resource landing view driven by `interlinedlist://…/shared/{token}` and pasted share URLs; a **"Shared with me"** list section backed by `/api/lists/watching`. +- **Tests** subscriber gate on create, free-recipient claim, expired/revoked → 404, role capability matrix. **L.** + +**G5 · Search** *(free; quick, high utility)* +- **Kit** `search(query:)` on Messages (**GET** `/api/messages/search?q=`), Lists (`GET /api/lists/search`), Documents (`GET /api/documents/search`). +- **App** global `⌘F` search field in the sidebar fanning out to all three (grouped results) + per-surface in-context search. **M.** + +**G4 · GitHub issue integration** *(backend live; dev-audience value; linking already built)* +- **Kit** `GitHubEndpoint.swift`: `repos`, `issues(repo:state:)`, `createIssue`, `updateIssue`(PATCH labels/assignees), `comment`, `assignees`, `labels`, `nextIssueNumber`. DTOs `GitHubRepo/Issue/Label/User`. +- **Domain** `GitHubService` requiring a linked identity (reuse `UserService.identities()`; if unlinked, deep-link the **already-built** native OAuth flow — the 400 "not linked" is the exact state to handle). +- **App** "Create issue from message" (timeline overflow → repo/labels/assignees picker); issue browse/create/comment inside GitHub-backed Lists (which already `refresh`); inline "Link GitHub" CTA. +- **Tests** unlinked → guided link, create/comment happy + validation, picker hydration. **M.** + +### Wave 3 — Organization, reach & the purchase path (P2–P3) + +**G6 · List Folders** *(subscriber create-gate; lists already have `folderId`)* +- **Kit** `ListFoldersEndpoint.swift`: `folders` `GET /api/folders`, `create(name:parentId:)`, `renameOrMove(id:name?:parentId?:)` `PUT`, `delete(id:)` (detaches lists to root). Reuse the document-folder flat-array + `parentId` tree builder. +- **App** folder tree in the Lists sidebar, drag-to-move (client-side cycle guard; server also rejects), assign a list's `folderId`. **M.** + +**G7 · X / Twitter cross-posting** *(OAuth `configured:true`; small composer extension)* +- **Kit** add `.twitter` to `OAuthProvider` (authorize/status generalize already); add the X cross-post flag to `CreateMessageRequest` (**confirm field name — spike S1**). +- **App** X toggle + readiness alongside the existing three; include X in the cross-post result sheet. **S.** + +**~~G8 · In-app billing~~ — OUT OF SCOPE.** Billing is managed in the online app (owner decision 2026-07-31); the native app never sells or manages subscriptions. (Stripe routes were also 404/not-deployed.) The app continues to *read* `customerStatus` for feature-gating only. + +**G9 · Push notifications (APNs)** *(route live; needs entitlement — spike S2 first)* +- **Kit** `registerPush(token:platform:)` `POST /api/push/register`; `unregisterPush(...)` (confirm verb — POST → 405, likely DELETE). +- **App** register device token on launch/sign-in, unregister on sign-out; real pushes replace/augment tray polling (keep polling fallback). **M.** + +**G10 · Multi-account switching** *(session-only — spike S4)* +- Resolve the Bearer-vs-session constraint (`/api/auth/accounts` 401 under Bearer) before building. Options: drive a cookie session for these routes, or request a bearer variant upstream. Then: account switcher UI + per-account `KeychainCredentialStore` + cache reset on switch. **M.** + +### Wave 4 — Long-tail parity (P3–P4) + +- **G11a · LinkedIn personal targets** — target picker in the composer from `/api/linkedin/{posting-targets}`; `POST /api/linkedin/sync-pages` to refresh. **S.** +- **G12 · Server-side document templates** — migrate the client template catalog onto `/api/documents/templates` + `/api/documents/from-template`; adopt `/api/documents/tree` for one-call sidebar hydration. **S.** +- **G14 · `/api/limits` composer validation** — optional; wire message-length / media limits into the composer. Not billing. **S.** +- **Follow-ups from §1:** per-doc/per-thread Markdown-export buttons (engine ready) · ERD list view (scope first) · public `ListDetailView` grid. +- **Deferred pending demand:** **G11b** LinkedIn org pages (upstream `orgScopesEnabled:false`), **G13** document presence / live cursors. + +--- + +## 6. API-drift / migration pass (corrected against live; do alongside Wave 1) + +Only two drift items survived live verification — the orgs/users-current "drift" was a false alarm (§2). + +- **D1 — Scheduling:** add `POST /api/messages/{id}/schedule`; keep the working `scheduledAt`-on-create as fallback. *(low risk; verify which the live server prefers)* +- **D2 — Public profile:** add `Users.profile(username:)` `GET /api/users/{username}` (live, returns a real profile) and **replace the decision-0002 fallback** in `SocialService.profile` — real bios/counts instead of projecting from the first public message. Keep the fallback only for pre-migration servers. +- **D3 — Replies verb (verify):** OpenAPI shows `POST /api/messages/{id}/replies`; the app uses `GET`. Confirm and align. +- **Not drift (keep as-is):** `/api/user*` and `/api/organizations*` — the live server serves these, not `/api/users/current` or `/api/orgs`. + +--- + +## 7. Ship gating (M7) — gates *release*, orthogonal to parity + +Carried from `feature-gaps.md` §4 — can proceed in parallel with any wave: +- **Sparkle** — `SparkleController` + SPM dep in place; verify the update-check call, `SUFeedURL`, `SUPublicEDKey`, key generation. +- **Appcast hosting** — needs distribution infra on interlinedlist.com. +- **Notarization** — `scripts/notarize.sh` / `package-pkg.sh` need the Developer ID certs (`CODESIGN_IDENTITY` / `INSTALLER_IDENTITY` in `.env` are placeholders). +- **Target:** notarized **`.pkg`** (closed-source private repo; no `LICENSE`). Not App Store (yet). Billing is handled in the online app — the native app has no in-app purchase surface. +- App Store extras (if pursued later): Privacy/Support pages, and now-satisfiable `/api/limits`. + +--- + +## 8. Coverage tracking — re-baseline `api-coverage.md` + +**First task of this whole effort:** `docs/api-coverage.md` is stale at 98 endpoints against the 2026-06-11 surface. Re-baseline it to the current `openapi.json` so coverage stays *verified, not assumed*: +1. Regenerate the endpoint inventory from `openapi.json` (~150 rows). +2. Mark the existing 98 as ☑/◐ per their current state (unchanged). +3. Add **new rows** for every gap here (DM ×11, moderation ×10, share-links/collaborators ×~16, list-folders ×4, github ×8, search ×3, push ×2, stripe ×2, twitter-auth ×3, linkedin ×4, limits ×1, templates ×4, presence ×2) — all starting ☐/☐. +4. Resolve the now-obsolete footnotes: **fn 8** (public profile — endpoint now exists, D2), **fn 12** (OAuth linking — now built). +5. Keep the maintenance rule: a row flips ◐→☑ only when a tested App-layer view model drives it end-to-end. + +--- + +## 9. Sequencing & rationale + +1. **Re-baseline `api-coverage.md`** (§8) — half-day; makes all tracking honest. +2. **Wave 1 — G1 DMs + G2 Moderation** — highest daily value, both free-tier, and they interlock (block/mute gate DM eligibility). Ship together. +3. **Wave 2 — G3 Sharing + G5 Search + G4 GitHub** — the collaboration story; G3 is the biggest single feature, G5 a fast win, G4 cashes in the already-built OAuth linking. +4. **Wave 3 — G6/G7/G9/(G10)** — organization and reach. (G8 billing removed — managed online.) +5. **Wave 4 — G11a/G12/G14 + follow-ups** — long-tail; re-confirm demand before G11b/G13. +6. **Migration pass (§6)** rides alongside Wave 1. **Ship gating (§7)** runs in parallel throughout. + +--- + +## 10. Open questions / spikes + +**Resolved this session (no spike needed):** DM/moderation/share-links/list-folders/search/push/github/templates backends are live (§2); `/api/limits` shape known; X OAuth configured; public-profile endpoint live; orgs/users paths confirmed unchanged. + +**Remaining, do before the dependent wave:** +- **S1 (G7):** exact `CreateMessageRequest` field for X/Twitter cross-post — read the `POST /api/messages` request schema in `openapi.json`. +- **S2 (G9):** does the notarized non-sandboxed `.pkg` support APNs, and what provisioning is needed? Also confirm `unregister` verb (POST→405). +- **S3 (G3):** does the list "watchers" model unify cleanly with the new `watcher`/`collaborator`/`manager` roles, or are they two systems? +- **S4 (G10):** how do native Bearer clients use `/api/auth/accounts` + `/api/auth/switch` given the 401-under-Bearer? Session bridge or upstream bearer variant. +- **S5 (Following feed):** re-verify whether `GET /api/messages` now serves a `scope=following` feed (old NB-1) — may already be closable. +- ~~S6 (G8 Stripe)~~ — removed; billing is out of scope (managed online). + +--- + +## Appendix — live probe log (2026-07-31, account `messenger`, tier `subscriber`) + +Read-only `Authorization: Bearer` probes. `[404 HTML]` = no API route (Next.js shell). + +``` +POST /api/auth/sync-token 200 (token acquired) +GET /api/user 200 customerStatus:"subscriber", emailVerified:true +GET /api/limits 200 image maxBytes 1468006 / 1200px jpeg,png,gif,webp; video 3145728 mp4,mov; message 5000 +GET /api/dm 200 {items:[], nextCursor:null} +GET /api/dm?folder=sent 200 ok +GET /api/dm/recipients 200 1 eligible recipient (mutual follower) +GET /api/dm/unread-count 200 {count:0} +GET /api/user/blocks 200 {blockedUsers:[], pagination} +GET /api/user/mutes 200 {mutedUsers:[], pagination} +GET /api/lists/{id}/share-links 200 {shareLinks:[]} +GET /api/lists/{id}/watchers 200 {watchers:[], pagination} +GET /api/lists/watching 200 real "shared-with-me" list +GET /api/documents/{id}/share-links 200 {shareLinks:[]} +GET /api/documents/{id}/collaborators 200 {collaborators:[], pagination} +GET /api/folders 200 {folders:[]} (list folders) +GET /api/lists?limit=2 200 rows carry folderId, parentId, source, githubRepo +GET /api/lists/search?q=a 200 ok +GET /api/documents/search?q=a 200 real docs +GET /api/messages/search?q=a 200 (POST → 405; search is GET) +GET /api/documents/templates 200 seeded server templates + _templates folder +GET /api/documents/tree 200 folders+documents in one payload +GET /api/linkedin/targets 200 personal target +GET /api/linkedin/posting-targets 200 enabled:true, orgScopeMissing:true +GET /api/github/repos 400 "GitHub account not linked" (route live) +GET /api/orgs 404 [404 HTML] → app uses /api/organizations (200) +GET /api/organizations 200 real org "Bikey Life" +GET /api/organizations/{id}/linkedin-page 404 [404 HTML] +GET /api/orgs/{id}/linkedin-page 404 [404 HTML] (org LinkedIn not deployed) +GET /api/users/current 404 user_not_found → app uses /api/user (200) +GET /api/users/messenger 200 real public profile (endpoint exists → D2) +GET /api/users/messenger/lists 200 ok +GET /api/auth/accounts 401 Unauthorized under Bearer (session-only → G10) +GET /api/auth/twitter/status 200 configured:true +GET /api/auth/linkedin/status 200 configured:true, orgScopesEnabled:false +GET /api/auth/bluesky/status 200 configured:true +POST /api/push/register {} 400 "token is required" (route live) +POST /api/push/unregister {} 405 (not POST — likely DELETE) +GET /api/notifications 200 unreadCount:5 +``` +``` +Auth: POST /api/auth/sync-token {email,password} → {token:"il_tok_…"} (64-char); then Authorization: Bearer . +No live writes were performed (no DMs sent, no data created/modified/deleted). +```