Skip to content

Commit e638e05

Browse files
authored
Merge pull request #6 from CompositeCode/feature/web-parity-batch-2026-07
Web parity batch 2: DMs, Moderation, Sharing, Search, List Folders, Templates, X cross-post, profiles
2 parents e99ab2e + 2c76025 commit e638e05

156 files changed

Lines changed: 15481 additions & 151 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

App/Composition/AppEnvironment.swift

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ final class AppEnvironment: ObservableObject {
9494
/// doubles substitute in.
9595
let documentsService: DocumentsServicing
9696

97+
/// The server-side document-templates surface (the-gaps.md G12) — the
98+
/// user's own saved template documents. Distinct from the client-side
99+
/// `DocumentTemplate.builtIn` catalog: this lists / creates-from / seeds
100+
/// server templates so the "New from Template" picker can show a
101+
/// "Your templates" section. Exposed as the protocol so test doubles
102+
/// substitute in. Ungated, so it is a plain stored service (no live
103+
/// entitlements rebuild).
104+
let documentTemplatesService: DocumentTemplatesServicing
105+
97106
/// The owner of `/api/documents/sync` — the M4 offline backbone
98107
/// (PLAN.md §3, §6 M4). The App layer reaches in directly for the
99108
/// `syncNow()` button on the toolbar; the rest of the App talks to
@@ -150,6 +159,71 @@ final class AppEnvironment: ObservableObject {
150159
/// endpoints and returns domain `CSVExport` values.
151160
let exportsService: ExportsServicing
152161

162+
/// The global-search surface the Search feature binds against
163+
/// (the-gaps.md G5). Exposed as the protocol so test doubles
164+
/// substitute in. Fans out over messages / lists / documents and
165+
/// returns domain values.
166+
let search: SearchServicing
167+
168+
/// The moderation surface the Blocked & Muted settings pane and the
169+
/// per-user / per-message report affordances bind against
170+
/// (the-gaps.md G2). Exposed as the protocol so test doubles
171+
/// substitute in.
172+
let moderation: ModerationServicing
173+
174+
/// The Direct Messages surface the Messages feature binds against
175+
/// (the-gaps.md G1). Exposed as the protocol so test doubles
176+
/// substitute in. Wraps the `/api/messages/*` DM endpoints (folders,
177+
/// thread, send, recipients, unread, read/trash/restore) and returns
178+
/// domain `DirectMessage` / `DMThread` / `DMPage` values.
179+
let directMessages: DirectMessagesServicing
180+
181+
/// Cross-window event bus for the Direct Messages feature (the-gaps.md
182+
/// G1). Sending / reading / trashing posts to this bus so the DM list,
183+
/// an open thread, and the unread-badge coordinator update in place
184+
/// without a refetch.
185+
let directMessagesEventBus: DirectMessagesEventBus
186+
187+
/// Live list-folders surface (the-gaps.md G6). Folders are
188+
/// subscriber-gated on create, so the service is rebuilt on each
189+
/// access with the current account's entitlements — mirroring
190+
/// `liveEntitlements`, so a mid-session subscription change re-gates
191+
/// folder creation without stale state. The read / rename / move /
192+
/// delete paths are ungated and unaffected.
193+
var listFolders: ListFoldersServicing {
194+
ListFoldersService(
195+
api: listFoldersAPI,
196+
entitlements: EntitlementsService(user: currentUserStore.currentUser)
197+
)
198+
}
199+
200+
/// The shared kit-layer API client retained so `listFolders` can
201+
/// rebuild the folders service with live entitlements on each access.
202+
private let listFoldersAPI: APIClientProtocol
203+
204+
/// Live share-links surface (the-gaps.md G3). Creating a link is
205+
/// subscriber-gated, so — exactly like `listFolders` — the service is
206+
/// rebuilt on each access with the current account's entitlements, so a
207+
/// mid-session subscription change re-gates link creation without stale
208+
/// state. The list / resolve / revoke / claim paths are ungated and
209+
/// unaffected.
210+
var sharing: SharingServicing {
211+
SharingService(
212+
api: sharingAPI,
213+
entitlements: EntitlementsService(user: currentUserStore.currentUser)
214+
)
215+
}
216+
217+
/// The shared kit-layer API client retained so `sharing` can rebuild the
218+
/// sharing service with live entitlements on each access.
219+
private let sharingAPI: APIClientProtocol
220+
221+
/// Base URL used to compose canonical web share URLs (`…/lists/shared/…`)
222+
/// when the server does not return a pre-built `ShareLink.url`. Defaults
223+
/// to the production host; the App layer reads it through the domain-only
224+
/// `URL` type so no kit import leaks into the sharing views.
225+
let shareBaseURL: URL
226+
153227
/// Designated initializer used by tests and previews that want to
154228
/// inject a fully synthetic service graph. Production code calls
155229
/// `live()` instead.
@@ -163,6 +237,7 @@ final class AppEnvironment: ObservableObject {
163237
listsEventBus: ListsEventBus,
164238
listsStore: ListsStore,
165239
documentsService: DocumentsServicing,
240+
documentTemplatesService: DocumentTemplatesServicing,
166241
documentSyncEngine: DocumentSyncEngine,
167242
documentSyncEvents: AsyncStream<DocumentSyncEvent>,
168243
notificationsService: NotificationsServicing,
@@ -171,7 +246,14 @@ final class AppEnvironment: ObservableObject {
171246
followRelationshipReader: FollowRelationshipReading,
172247
orgService: OrgServicing,
173248
userService: UserServicing,
174-
exportsService: ExportsServicing
249+
exportsService: ExportsServicing,
250+
search: SearchServicing,
251+
moderation: ModerationServicing,
252+
directMessages: DirectMessagesServicing,
253+
directMessagesEventBus: DirectMessagesEventBus,
254+
listFoldersAPI: APIClientProtocol,
255+
sharingAPI: APIClientProtocol,
256+
shareBaseURL: URL
175257
) {
176258
self.messages = messages
177259
self.lists = lists
@@ -182,6 +264,7 @@ final class AppEnvironment: ObservableObject {
182264
self.listsEventBus = listsEventBus
183265
self.listsStore = listsStore
184266
self.documentsService = documentsService
267+
self.documentTemplatesService = documentTemplatesService
185268
self.documentSyncEngine = documentSyncEngine
186269
self.documentSyncEvents = documentSyncEvents
187270
self.notificationsService = notificationsService
@@ -191,6 +274,13 @@ final class AppEnvironment: ObservableObject {
191274
self.orgService = orgService
192275
self.userService = userService
193276
self.exportsService = exportsService
277+
self.search = search
278+
self.moderation = moderation
279+
self.directMessages = directMessages
280+
self.directMessagesEventBus = directMessagesEventBus
281+
self.listFoldersAPI = listFoldersAPI
282+
self.sharingAPI = sharingAPI
283+
self.shareBaseURL = shareBaseURL
194284
}
195285

196286
/// Builds the production service graph:
@@ -284,6 +374,11 @@ final class AppEnvironment: ObservableObject {
284374
api: api,
285375
sync: documentSyncEngine
286376
)
377+
// Server document templates (the-gaps.md G12). Reuses the same
378+
// kit-layer `APIClient` like the other services do — the
379+
// `/api/documents/templates` endpoints are already routed by the
380+
// shared `authTransport`. Ungated, so a plain stored service.
381+
let documentTemplatesService = DocumentTemplatesService(api: api)
287382
let documentSyncEvents = documentSyncEngine.events
288383
// M5 — Notifications + Social write surface (PLAN.md §6 M5).
289384
// `NotificationsService` already exists with the read + mark
@@ -307,6 +402,24 @@ final class AppEnvironment: ObservableObject {
307402
// decision-0001 session-only allowlist (`/api/exports/*`), already
308403
// routed by the shared `authTransport`.
309404
let exportsService = ExportsService(api: api)
405+
// Web-parity batch (the-gaps.md G5 / G2 / G6). All three reuse the
406+
// same kit-layer `APIClient` like `lists` / `social` do — their
407+
// endpoints are already routed by the shared `authTransport`.
408+
// • Search — full-text over messages / lists / documents (G5).
409+
// • Moderation — blocks / mutes / reports (G2).
410+
// • List folders — the API client is retained on the environment
411+
// so `listFolders` can rebuild the service with live
412+
// entitlements per access (folders are subscriber-gated on
413+
// create, G6).
414+
let search = SearchService(api: api)
415+
let moderation = ModerationService(api: api)
416+
// Direct Messages (the-gaps.md G1). Reuses the same kit-layer
417+
// `APIClient` like `lists` / `social` / `search` do — the DM
418+
// endpoints are already routed by the shared `authTransport`. The
419+
// event bus is a singleton so the DM list, an open thread, and the
420+
// dock-badge coordinator all see the same stream.
421+
let directMessages = DirectMessagesService(api: api)
422+
let directMessagesEventBus = DirectMessagesEventBus()
310423
return AppEnvironment(
311424
messages: messages,
312425
lists: lists,
@@ -317,6 +430,7 @@ final class AppEnvironment: ObservableObject {
317430
listsEventBus: listsEventBus,
318431
listsStore: listsStore,
319432
documentsService: documentsService,
433+
documentTemplatesService: documentTemplatesService,
320434
documentSyncEngine: documentSyncEngine,
321435
documentSyncEvents: documentSyncEvents,
322436
notificationsService: notificationsService,
@@ -325,7 +439,20 @@ final class AppEnvironment: ObservableObject {
325439
followRelationshipReader: followRelationshipReader,
326440
orgService: orgService,
327441
userService: userService,
328-
exportsService: exportsService
442+
exportsService: exportsService,
443+
search: search,
444+
moderation: moderation,
445+
directMessages: directMessages,
446+
directMessagesEventBus: directMessagesEventBus,
447+
listFoldersAPI: api,
448+
// Share Links (the-gaps.md G3) reuse the same kit-layer
449+
// `APIClient`; the API client is retained on the environment so
450+
// `sharing` can rebuild the service with live entitlements per
451+
// access (link creation is subscriber-gated). The base URL feeds
452+
// the canonical web-URL builder for links the server returns
453+
// without a pre-built `url`.
454+
sharingAPI: api,
455+
shareBaseURL: InterlinedKit.defaultBaseURL
329456
)
330457
}
331458

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// DirectMessagesEventBus
2+
//
3+
// Cross-window pub/sub bus for the Direct Messages feature (the-gaps.md
4+
// G1). Mirrors `NotificationsEventBus` / `ComposerEventBus`: an internal
5+
// actor holds the live continuations keyed by UUID; `events()` returns
6+
// an `AsyncStream<DirectMessagesEvent>` per subscriber.
7+
//
8+
// The bus lets the DM list, an open thread, the composer sheet, and the
9+
// unread-badge coordinator react in place to writes performed by other
10+
// windows / menu commands without forcing a full refetch:
11+
// - sending a message updates the sender's conversation-list preview,
12+
// - reading a thread decrements the unread pip,
13+
// - a fresh `unreadCount()` read republishes the authoritative total.
14+
//
15+
// Decision 0003 compliance: this file lives in `App/Composition/` and
16+
// consumes only `InterlinedDomain`; no kit symbol crosses the boundary.
17+
18+
import Foundation
19+
import InterlinedDomain
20+
21+
/// One event a Direct Messages surface emits after a successful write /
22+
/// read. Subscribers translate these into pure local mutations (a
23+
/// conversation-list preview swap, an unread-pip decrement) or, for the
24+
/// badge coordinator, into a dock-badge write.
25+
enum DirectMessagesEvent: Sendable, Equatable {
26+
27+
/// A fresh `unreadCount()` read landed. The badge aggregator writes
28+
/// this as the DM contribution to the dock badge; a sidebar pip binds
29+
/// to it too.
30+
case unreadCountChanged(Int)
31+
32+
/// A message was sent to `recipientUsername`. Open list / thread
33+
/// surfaces for that conversation append it in place.
34+
case messageSent(recipientUsername: String, message: DirectMessage)
35+
36+
/// A thread with `username` was opened and its inbound messages
37+
/// marked read. Peer surfaces drop that conversation's unread pip.
38+
case threadRead(username: String)
39+
}
40+
41+
/// Shared event bus for the Direct Messages feature. Use `events()` for
42+
/// a subscription stream; terminate by cancelling the consuming task.
43+
final class DirectMessagesEventBus: Sendable {
44+
45+
private let storage = Storage()
46+
47+
init() {}
48+
49+
/// Returns an `AsyncStream` that yields every event posted after
50+
/// subscription. The stream finishes when the consumer cancels.
51+
func events() -> AsyncStream<DirectMessagesEvent> {
52+
let id = UUID()
53+
return AsyncStream { continuation in
54+
Task { await self.storage.register(id: id, continuation: continuation) }
55+
continuation.onTermination = { _ in
56+
Task { await self.storage.unregister(id: id) }
57+
}
58+
}
59+
}
60+
61+
/// Publish an event to every active subscriber. Late subscribers do
62+
/// not receive past events.
63+
func post(_ event: DirectMessagesEvent) {
64+
Task { await storage.broadcast(event) }
65+
}
66+
67+
// MARK: - Storage
68+
69+
/// Holds the live continuations keyed by registration UUID. An actor
70+
/// because publishers and subscribers aren't serialized.
71+
private actor Storage {
72+
private var continuations: [UUID: AsyncStream<DirectMessagesEvent>.Continuation] = [:]
73+
74+
func register(id: UUID, continuation: AsyncStream<DirectMessagesEvent>.Continuation) {
75+
continuations[id] = continuation
76+
}
77+
78+
func unregister(id: UUID) {
79+
continuations[id] = nil
80+
}
81+
82+
func broadcast(_ event: DirectMessagesEvent) {
83+
for continuation in continuations.values {
84+
continuation.yield(event)
85+
}
86+
}
87+
}
88+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// DirectMessagesUnreadBadgeCoordinator
2+
//
3+
// Owns the dock-badge subscription glue for the Direct Messages feature
4+
// (the-gaps.md G1). Mirrors `NotificationsUnreadBadgeCoordinator`: it
5+
// listens on `DirectMessagesEventBus` and translates each event into the
6+
// DM contribution to the shared dock badge.
7+
//
8+
// Unlike the notifications coordinator (which predates the aggregator and
9+
// still writes a raw count through its own closure), this coordinator was
10+
// born after `UnreadBadgeAggregator` existed, so it reports into the
11+
// `.directMessages` slot of the aggregator via the injected closure. The
12+
// aggregator sums the DM and notifications slots and performs the single
13+
// actual badge write. This keeps DMs from clobbering the notifications
14+
// contribution and vice-versa.
15+
//
16+
// The DM list view model posts `unreadCountChanged(...)` after every
17+
// `unreadCount()` read and after a thread is opened / read; the
18+
// coordinator translates those into the current DM unread total.
19+
//
20+
// Decision 0003 compliance: lives in `App/Composition/` and imports only
21+
// `Foundation` and `InterlinedDomain`; the AppKit reach is hidden behind
22+
// the `@MainActor` closure the composition root supplies.
23+
24+
import Foundation
25+
import InterlinedDomain
26+
27+
/// Drives the DM contribution of the dock-tile badge from
28+
/// `DirectMessagesEventBus` events. Minimal by design — every business
29+
/// rule lives in the DM view models; the coordinator only folds events
30+
/// into a count and reports it upward.
31+
final class DirectMessagesUnreadBadgeCoordinator: @unchecked Sendable {
32+
33+
/// Reports the DM unread count into the shared badge aggregator.
34+
/// Injected so tests record without AppKit.
35+
private let reportCount: @MainActor @Sendable (Int) -> Void
36+
37+
/// The event bus the coordinator subscribes to.
38+
private let bus: DirectMessagesEventBus
39+
40+
/// Best-effort tracker of the last authoritative DM unread count so a
41+
/// `threadRead` event can decrement without a fresh `unreadCount()`
42+
/// round-trip. `nil` until the first `unreadCountChanged` arrives.
43+
private var lastKnownUnread: Int?
44+
45+
/// Subscription task; `nil` until `start()` is called.
46+
private var subscription: Task<Void, Never>?
47+
48+
init(
49+
bus: DirectMessagesEventBus,
50+
reportCount: @escaping @MainActor @Sendable (Int) -> Void
51+
) {
52+
self.bus = bus
53+
self.reportCount = reportCount
54+
}
55+
56+
/// Begins consuming the event stream. Safe to call multiple times —
57+
/// re-subscribing replaces the prior task.
58+
func start() {
59+
subscription?.cancel()
60+
let stream = bus.events()
61+
let reportCount = self.reportCount
62+
subscription = Task { [weak self] in
63+
for await event in stream {
64+
guard let self else { return }
65+
let count = await self.fold(event: event)
66+
await MainActor.run { reportCount(count) }
67+
}
68+
}
69+
}
70+
71+
/// Stops consuming events. Idempotent.
72+
func stop() {
73+
subscription?.cancel()
74+
subscription = nil
75+
}
76+
77+
/// Visible for tests — fold an event into the next DM unread count.
78+
/// Returns the value the coordinator would have reported for `event`.
79+
/// Pure: no AppKit, no I/O.
80+
func fold(event: DirectMessagesEvent) async -> Int {
81+
switch event {
82+
case .unreadCountChanged(let count):
83+
let clamped = max(0, count)
84+
lastKnownUnread = clamped
85+
return clamped
86+
case .threadRead:
87+
// Opening a thread clears that conversation's unread inbound
88+
// messages; without a per-conversation count we conservatively
89+
// leave the known total in place and let the next
90+
// `unreadCountChanged` (posted right after mark-read) supply
91+
// the authoritative value. Reporting the last-known avoids a
92+
// flicker to a wrong number.
93+
return lastKnownUnread ?? 0
94+
case .messageSent:
95+
// Sending a message never changes *your own* unread count.
96+
return lastKnownUnread ?? 0
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)