Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0baf892
feat(lists): add select and markdown schema field types
Adron Jul 18, 2026
cc40023
feat(timeline): render rich link previews on posts
Adron Jul 18, 2026
e13da86
feat(documents): add document templates
Adron Jul 18, 2026
9c1ecf4
feat(export): add MarkdownExporter engine
Adron Jul 18, 2026
73b4acc
docs: reconcile parity gaps, blockages, and feature-status with shipp…
Adron Jul 18, 2026
0916b33
docs: fold parity backend asks into blocker-prompts.md
Adron Jul 18, 2026
74ab1f1
feat(export): wire Markdown export into the Export sheet
Adron Jul 18, 2026
0b6d39d
feat(lists): render owned-list rows as a real Table grid
Adron Jul 18, 2026
9228514
docs: mark feature-gaps §1.2 and §1.3 shipped
Adron Jul 18, 2026
b6476b4
docs: use asterisk emphasis in feature-gaps summary
Adron Jul 18, 2026
9cebc74
feat(parity): Search, Moderation, List Folders, Direct Messages (back…
Adron Jul 31, 2026
1e4a0e2
feat(parity): G3 Share Links backend (lists + documents)
Adron Jul 31, 2026
8871087
feat(parity): G3 Share Links App UI (share sheet + resolve/claim land…
Adron Jul 31, 2026
b48c7e5
feat(parity): D2 public-profile endpoint (replaces decision-0002 fall…
Adron Aug 1, 2026
c8cae3d
feat(parity): G7 X/Twitter cross-posting (additive mirror of Bluesky/…
Adron Aug 1, 2026
6841141
feat(parity): G12 server document templates backend
Adron Aug 1, 2026
8c63e53
feat(parity): G12 server-template picker UI
Adron Aug 1, 2026
a560e6f
docs(parity): drop in-app billing (G8) — managed in the online app
Adron Aug 1, 2026
5b80ce1
feat(parity): G11a LinkedIn posting targets backend
Adron Aug 1, 2026
2c76025
docs(parity): record following-feed re-verification (still backend-bl…
Adron Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 129 additions & 2 deletions App/Composition/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -163,6 +237,7 @@ final class AppEnvironment: ObservableObject {
listsEventBus: ListsEventBus,
listsStore: ListsStore,
documentsService: DocumentsServicing,
documentTemplatesService: DocumentTemplatesServicing,
documentSyncEngine: DocumentSyncEngine,
documentSyncEvents: AsyncStream<DocumentSyncEvent>,
notificationsService: NotificationsServicing,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -317,6 +430,7 @@ final class AppEnvironment: ObservableObject {
listsEventBus: listsEventBus,
listsStore: listsStore,
documentsService: documentsService,
documentTemplatesService: documentTemplatesService,
documentSyncEngine: documentSyncEngine,
documentSyncEvents: documentSyncEvents,
notificationsService: notificationsService,
Expand All @@ -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
)
}

Expand Down
88 changes: 88 additions & 0 deletions App/Composition/DirectMessagesEventBus.swift
Original file line number Diff line number Diff line change
@@ -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<DirectMessagesEvent>` 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<DirectMessagesEvent> {
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<DirectMessagesEvent>.Continuation] = [:]

func register(id: UUID, continuation: AsyncStream<DirectMessagesEvent>.Continuation) {
continuations[id] = continuation
}

func unregister(id: UUID) {
continuations[id] = nil
}

func broadcast(_ event: DirectMessagesEvent) {
for continuation in continuations.values {
continuation.yield(event)
}
}
}
}
99 changes: 99 additions & 0 deletions App/Composition/DirectMessagesUnreadBadgeCoordinator.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

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
}
}
}
Loading
Loading