From b642a8e61aec1d3bf7d930d3fbb3bae752f0dfd0 Mon Sep 17 00:00:00 2001 From: alice Date: Sat, 23 May 2026 17:35:35 +0100 Subject: [PATCH] docs: capture data-layer rebuild design (CONTEXT.md, ADRs 0001-0015, plans 0001-0002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output of a /grill-with-docs session pinning the architectural decisions for ripping out the Mastodon-API-shaped adapter (src/utils/atproto-adapter.ts and its v1/v2 surface) and rebuilding Bluepy's data layer ATProto-native. Phase (b) is big-bang via an AFK coding agent; this design is the spec. - CONTEXT.md: glossary — At-URI, Permalink, PDS, AppView, Active AppView, Bluesky AppView, plus a dialogue showing how they relate. - ADRs 0001-0011: foundational decisions — at-URI URL form, @atproto/api over atcute (library swap deferred), domain verbs + hooks + lexicon types, three-client dispatch (pds/active-appview/bsky-appview), mutations with selective optimistic, Zustand for UI state, hook-level loading (no Suspense), flat pagination shape, cache-key factory + account scoping, session lifecycle (OAuth lib owns OAuth, Zustand owns UI), render pipeline (facet rendering + sanitizer outside data layer). - ADRs 0012-0015: compose-specific — intent-keyed multi-draft, upload-on-submit (because ATProto has no deleteBlob), all-or-nothing submit pipeline with warn-on-aux-fail for threadgate/postgate, author picker for multi-account users. - docs/plan/0001-data-layer-rebuild.md: Phase (b) spec — file structure, per-module signatures, provider tree, consumer migration patterns, acceptance criteria (incl. zero new `as any` in touched code). - docs/plan/0002-compose-flow.md: compose subsystem spec — state model, facet detection, mentions autocomplete, link card via cardyb, submit pipeline, video upload via service auth, acceptance criteria. --- CONTEXT.md | 37 ++ docs/adr/0001-at-uri-canonical-url-form.md | 3 + .../0002-data-layer-stays-on-atproto-api.md | 3 + docs/adr/0003-data-layer-shape.md | 3 + docs/adr/0004-appview-dispatch.md | 17 + ...005-mutations-with-selective-optimistic.md | 9 + docs/adr/0006-zustand-for-ui-state.md | 7 + docs/adr/0007-hook-level-loading-state.md | 7 + docs/adr/0008-pagination-shape.md | 18 + docs/adr/0009-cache-key-factory.md | 21 + docs/adr/0010-session-lifecycle.md | 15 + docs/adr/0011-render-pipeline.md | 9 + docs/adr/0012-intent-keyed-multi-draft.md | 9 + docs/adr/0013-upload-on-submit.md | 9 + docs/adr/0014-all-or-nothing-submit.md | 7 + docs/adr/0015-compose-author-picker.md | 7 + docs/plan/0001-data-layer-rebuild.md | 359 ++++++++++++++++++ docs/plan/0002-compose-flow.md | 226 +++++++++++ 18 files changed, 766 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-at-uri-canonical-url-form.md create mode 100644 docs/adr/0002-data-layer-stays-on-atproto-api.md create mode 100644 docs/adr/0003-data-layer-shape.md create mode 100644 docs/adr/0004-appview-dispatch.md create mode 100644 docs/adr/0005-mutations-with-selective-optimistic.md create mode 100644 docs/adr/0006-zustand-for-ui-state.md create mode 100644 docs/adr/0007-hook-level-loading-state.md create mode 100644 docs/adr/0008-pagination-shape.md create mode 100644 docs/adr/0009-cache-key-factory.md create mode 100644 docs/adr/0010-session-lifecycle.md create mode 100644 docs/adr/0011-render-pipeline.md create mode 100644 docs/adr/0012-intent-keyed-multi-draft.md create mode 100644 docs/adr/0013-upload-on-submit.md create mode 100644 docs/adr/0014-all-or-nothing-submit.md create mode 100644 docs/adr/0015-compose-author-picker.md create mode 100644 docs/plan/0001-data-layer-rebuild.md create mode 100644 docs/plan/0002-compose-flow.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..0a540384 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,37 @@ +# Bluepy + +A Bluesky client whose UI descends from Phanpy (a Mastodon client) but whose data layer is being rebuilt to be ATProto-shaped end to end. This glossary captures the terms that distinguish Bluepy's ATProto-native choices from generic web-app vocabulary and from the Phanpy/Mastodon vocabulary the codebase is moving away from. + +## Language + +### Identifiers & permalinks + +**At-URI** (`at://...`): +The ATProto-native identifier for a record. Always serialized with the `at://` prefix, never `at:/`. Full form is `at:////`. Bluepy uses at-URIs as both record references inside the data layer and as URLs in the browser (see Permalink). +_Avoid_: ATP URI, AT-URI without prefix, Bluesky URI + +**Permalink**: +The at-URI of a record (post, profile, list, feed generator) used directly as the navigation URL — e.g. `bluepy.social/at://did:plc:.../app.bsky.feed.post/3kxabc`. Bluepy treats the at-URI as the canonical identifier *and* the canonical URL form; there is no separate web-style permalink. No legacy fallback. +_Avoid_: Bluesky URL, web URL, post URL, share link, public URL + +### Services Bluepy talks to + +**PDS** (Personal Data Server): +The ATProto server that holds the user's repository and signs writes. Bluepy talks to the PDS for any operation requiring identity: writes, mutes/blocks/follows, authenticated reads, blob uploads. One PDS per logged-in account. +_Avoid_: Bluesky server, home server + +**AppView**: +A service that indexes ATProto records and serves Bluesky-shape lexicon queries (`app.bsky.*`) — feeds, threads, profiles, search, notifications. Bluepy treats the AppView as configurable: different users can point Bluepy at different AppView implementations. + +**Active AppView**: +The AppView a given user has configured Bluepy to use. Defaults to the official Bluesky AppView, but users can switch to e.g. Blacksky's AppView. Public reads route here. The active-AppView setting is per-account. + +**Bluesky AppView**: +The official Bluesky-operated AppView (`did:web:api.bsky.app#bsky_appview`, public endpoint `public.api.bsky.app`). Bluepy keeps a separate client pinned to this AppView as a fallback target for operations that the active AppView (e.g. Blacksky's) doesn't implement or implements incorrectly. + +## Example dialogue + +> **A**: A user reports that trending topics don't load when they're signed into Blacksky's AppView. What's going on? +> **B**: The user's **Active AppView** is Blacksky, which doesn't implement `app.bsky.unspecced.getTrendingTopics`. That operation has to be force-routed to the **Bluesky AppView** instead. Check `src/data/feeds.ts` — it should be using the Bluesky-pinned client for that call, not the active one. +> **A**: And the share button copies the wrong link format. What should it copy? +> **B**: The **Permalink** — the at-URI form. `bluepy.social/at://did:plc:abc.../app.bsky.feed.post/3kxyz`, not `bsky.app/profile/handle/post/rkey`. No legacy URL shape. diff --git a/docs/adr/0001-at-uri-canonical-url-form.md b/docs/adr/0001-at-uri-canonical-url-form.md new file mode 100644 index 00000000..e3f123bd --- /dev/null +++ b/docs/adr/0001-at-uri-canonical-url-form.md @@ -0,0 +1,3 @@ +# ADR-0001: At-URIs are the canonical Bluepy URL form + +Bluepy uses literal at-URI permalinks in the browser address bar (`bluepy.social/at:////`) instead of a conventional `/post//` or `/profile//post/` shape. The trade-off accepted: routing layer must bend to support `:` and `//` as literal path characters (`/:scheme://*`), and SEO-friendly URL shapes are forgone; in return, the URL form *is* the record identifier with no translation layer between address bar and data layer. There is no legacy fallback — `/post/...` shapes are not redirected or supported. diff --git a/docs/adr/0002-data-layer-stays-on-atproto-api.md b/docs/adr/0002-data-layer-stays-on-atproto-api.md new file mode 100644 index 00000000..6b832fbe --- /dev/null +++ b/docs/adr/0002-data-layer-stays-on-atproto-api.md @@ -0,0 +1,3 @@ +# ADR-0002: Data-layer rebuild stays on `@atproto/api`; library swap to atcute deferred + +Phase (b) of the data-layer rebuild deletes the Mastodon-API-shaped adapter (`src/utils/atproto-adapter.ts`, the `v1`/`v2` namespaces, `AdaptedStatus` / `AdaptedAccount` wrappers) and replaces it with a thin Bluepy data layer on top of `@atproto/api`'s `AtpAgent`. atcute (`mary-ext/atcute`) was a serious candidate — stricter lexicon-generated types, ~30-50 KB lighter, first-class Bluesky helpers (`threading`, `richtext-builder`, `moderation`) — but its `FetchHandlerObject` session model is not interoperable with `@atproto/oauth-client-browser`, which we keep, and it has no equivalent of `RichText.detectFacets()`. We accept those costs only after the new Bluepy data-layer interface is in place and the seam is well-defined; at that point the client swap becomes mechanical. Until then, the architectural goal (kill the Masto-shape) is separated from the library choice. diff --git a/docs/adr/0003-data-layer-shape.md b/docs/adr/0003-data-layer-shape.md new file mode 100644 index 00000000..df4a24fb --- /dev/null +++ b/docs/adr/0003-data-layer-shape.md @@ -0,0 +1,3 @@ +# ADR-0003: Data layer surfaces domain verbs + React hooks; lexicon types are the contract + +The new Bluepy data layer is organised flat-by-domain (`src/data/posts.ts`, `profiles.ts`, `feeds.ts`, `notifications.ts`, `lists.ts`, `feed-generators.ts`, `search.ts`, `bookmarks.ts`), with each module exporting both imperative functions (`getPostThread(uri)`, `createPost(record)`) and React hooks built on TanStack Query (`usePost(uri)`, `useThread(uri)`). Types flowing through the layer are `@atproto/api`'s generated lexicon types directly (`AppBskyFeedPost.Record`, `AppBskyActorDefs.ProfileViewDetailed`); no Mastodon-shaped wrappers, no `AdaptedXxx` aliases. The lexicon's procedural verb names (`getPostThread`, `listRecords`) are a network detail kept inside the wrapper — the app deals in domain verbs. TanStack Query is the cache. Hooks co-locate with their imperative counterparts so an AFK porting agent never has to decide which file a new operation belongs in. diff --git a/docs/adr/0004-appview-dispatch.md b/docs/adr/0004-appview-dispatch.md new file mode 100644 index 00000000..72a5c354 --- /dev/null +++ b/docs/adr/0004-appview-dispatch.md @@ -0,0 +1,17 @@ +# ADR-0004: Three-client dispatch model with Bluesky-AppView fallback + +The new data layer maintains three underlying ATProto agents, and every function in `src/data/*.ts` explicitly picks one: + +- **`pdsAgent`** — talks to the logged-in user's PDS. Used for writes (`com.atproto.repo.createRecord`, `putRecord`, `deleteRecord`, `applyWrites`, `uploadBlob`), authenticated mutations, and any operation tied to identity (mutes, blocks, follows, notifications-as-read). +- **`appviewAgent`** — talks to the user's **Active AppView** (per-account configurable; defaults to Bluesky's, but can be Blacksky's or any other). Used for the bulk of `app.bsky.*` reads — timeline, profile, thread, search, list, feed-generator. +- **`bskyAppviewAgent`** — pinned to the official **Bluesky AppView** (`public.api.bsky.app` / `did:web:api.bsky.app#bsky_appview`). Used as an escape hatch for operations that the active AppView does not implement or implements incorrectly (e.g. third-party AppViews like Blacksky don't ship `app.bsky.unspecced.*`, trending, discovery feeds). + +Each function in the data layer hard-codes which client it uses, based on what the operation does. The agent porting code does not invent the mapping — there is a single dispatch helper (`src/data/clients.ts`) that exposes the three agents and a documented rule: + +- Writes / identity-bound ops → `pdsAgent` +- `app.bsky.unspecced.*`, trending, discovery, anything not part of the Bluesky-AppView-portable surface → `bskyAppviewAgent` +- Everything else under `app.bsky.*` → `appviewAgent` + +The list of operations that require `bskyAppviewAgent` is empirical and lives in `src/data/clients.ts` as a documented exceptions list — it can grow as we learn what other AppView implementations miss, without changing the dispatch shape. + +Rejected alternatives: (a) single agent with `configureProxy()` toggled per call — the brittle thing the current adapter does; (b) per-call audience parameter — leaks dispatch decisions to call sites that will get them wrong; (c) ignore third-party AppViews and hard-code Bluesky — gives up a real Bluepy differentiator. diff --git a/docs/adr/0005-mutations-with-selective-optimistic.md b/docs/adr/0005-mutations-with-selective-optimistic.md new file mode 100644 index 00000000..2759a9f1 --- /dev/null +++ b/docs/adr/0005-mutations-with-selective-optimistic.md @@ -0,0 +1,9 @@ +# ADR-0005: Mutations use TanStack Query `useMutation` with selective optimistic updates + +Every write in `src/data/*.ts` (`createPost`, `deletePost`, `likePost`, `repostPost`, `followAccount`, `muteAccount`, `blockAccount`, `bookmarkPost`, `markNotificationsRead`, `editProfile`, etc.) is exposed as a TanStack Query `useMutation` hook in the same module that owns the corresponding read hooks. + +**Optimistic updates** are used for engagement actions where the cached object change is cheap to model and roll back: `likePost`/`unlikePost`, `repostPost`/`unrepostPost`, `followAccount`/`unfollowAccount`, `bookmarkPost`/`unbookmarkPost`, `muteAccount`/`unmuteAccount`, `blockAccount`/`unblockAccount`. These mutations patch the cached post/profile in `onMutate`, restore in `onError`, and revalidate in `onSettled`. Complex writes (compose, edit-profile, blob-upload-then-attach, mark-notifications-read) are wait-and-invalidate — they show honest pending state and do not pretend. + +**Invalidation breadth.** Writes that affect feed visibility (`createPost`, `deletePost`) invalidate broadly (every `['', 'feed', ...]` query and any thread query that could contain the post). Engagement writes invalidate surgically (the specific post or profile cache key). + +The agent porting code does not invent which writes get optimistic treatment — the list above is exhaustive; everything else is wait-and-invalidate. diff --git a/docs/adr/0006-zustand-for-ui-state.md b/docs/adr/0006-zustand-for-ui-state.md new file mode 100644 index 00000000..048b062b --- /dev/null +++ b/docs/adr/0006-zustand-for-ui-state.md @@ -0,0 +1,7 @@ +# ADR-0006: UI state lives in Zustand; Valtio is removed + +Valtio (`src/utils/states.ts`, ~587 LOC of global UI state, plus 36 import sites across the codebase) is removed during the Phase (b) rewrite. UI state — compose draft (text + attachments + reply ref before submit), modal stack, navigation/scroll state, transient toggles, filter selections — moves to one or a few Zustand stores under `src/state/`. + +Zustand was chosen over plain React Context + reducer (more boilerplate, every cross-cutting concern needs a new Provider, re-render scoping requires care) and Jotai (more conceptual overhead, worse fit for "compose draft is one big object"). Zustand has the same mental shape as Valtio (one global store accessed via a hook) but uses immutable updates instead of proxy mutation, which removes a class of subtle accidental-reactivity bugs. + +**Boundary**: server data does not live in Zustand. All record reads/writes flow through TanStack Query. Zustand holds only client-only UI state. diff --git a/docs/adr/0007-hook-level-loading-state.md b/docs/adr/0007-hook-level-loading-state.md new file mode 100644 index 00000000..8d604ad4 --- /dev/null +++ b/docs/adr/0007-hook-level-loading-state.md @@ -0,0 +1,7 @@ +# ADR-0007: Loading state is exposed as hook-level `isLoading` / `error`; no Suspense + +Every data hook in `src/data/*.ts` exposes `{ data, isLoading, error }` (TanStack Query's standard `useQuery` return shape). Components branch on `isLoading` and `error` locally. The app is not restructured around Suspense boundaries. + +Suspense was rejected as a concurrent change to the data-layer rebuild — it would require adding `` + `` structure to every page and embedded subtree, and that is a separate architectural commitment that does not have to share a release with the data-layer port. If Bluepy adopts Suspense later, it does so as a follow-on phase against the stable hook-level API. + +To prevent "spinner soup" when navigating between feeds or profiles, hook defaults set `placeholderData: keepPreviousData` — stale data stays on screen while the new fetch is in flight, with no flash. Mutation pending state surfaces via `useMutation`'s own `isPending`, not a global spinner. diff --git a/docs/adr/0008-pagination-shape.md b/docs/adr/0008-pagination-shape.md new file mode 100644 index 00000000..d9019dae --- /dev/null +++ b/docs/adr/0008-pagination-shape.md @@ -0,0 +1,18 @@ +# ADR-0008: Paginated hooks expose a flat shape; `useInfiniteQuery` is an implementation detail + +Every paginated hook in `src/data/*.ts` (`useTimelineFeed`, `useProfileFeed`, `useNotifications`, `useSearchPosts`, `useSearchActors`, `useListMembers`, etc.) returns the same shape regardless of underlying endpoint: + +```ts +{ + items: T[]; // flattened across all loaded pages + loadMore: () => void; // calls fetchNextPage() + hasMore: boolean; // hasNextPage + isLoadingMore: boolean; + isLoading: boolean; + error: Error | null; +} +``` + +`useInfiniteQuery`, `data.pages`, `pageParams`, and ATProto cursor strings are implementation details inside the hook. Components do not touch `data.pages`. The `T` varies per hook (`Post`, `Notification`, `ProfileView`, `ListItem`); TypeScript handles this via generics in `src/data/*.ts`. + +A shared helper under `src/data/_internal/use-infinite.ts` standardises the wrapping; bespoke hooks only supply the cursor + fetch function + page-to-items projection. diff --git a/docs/adr/0009-cache-key-factory.md b/docs/adr/0009-cache-key-factory.md new file mode 100644 index 00000000..197a920e --- /dev/null +++ b/docs/adr/0009-cache-key-factory.md @@ -0,0 +1,21 @@ +# ADR-0009: Cache keys come from a single factory in `src/data/keys.ts` + +Every TanStack Query `queryKey` and every `invalidateQueries` call in Bluepy reads its key from `src/data/keys.ts`. No inline string keys. The factory exposes pure functions (DID passed explicitly by the caller): + +```ts +export const keys = { + post: (did: string, uri: AtUri) => [did, 'post', uri] as const, + thread: (did: string, uri: AtUri) => [did, 'thread', uri] as const, + profile: (did: string, handle: string) => [did, 'profile', handle] as const, + feed: (did: string, generator: AtUri, opts?: FeedOpts) => [did, 'feed', generator, opts] as const, + timeline: (did: string) => [did, 'timeline'] as const, + notifications: (did: string, filter?: NotifFilter) => [did, 'notifications', filter] as const, + search: (did: string, query: string, type: 'posts'|'actors') => [did, 'search', type, query] as const, + bookmarks: (did: string) => [did, 'bookmarks'] as const, + // ... +}; +``` + +Cache keys are account-scoped — every key starts with the active DID. The hook reads the active DID from session context and passes it in. This makes data per-account: switching accounts surfaces the previously-active account's cached data without refetch, and concurrent multi-account is well-defined. + +Rejected alternatives: inline tuples (drift / typos between query and invalidate sites) and per-domain factories (no single source of truth for the schema). diff --git a/docs/adr/0010-session-lifecycle.md b/docs/adr/0010-session-lifecycle.md new file mode 100644 index 00000000..578c2025 --- /dev/null +++ b/docs/adr/0010-session-lifecycle.md @@ -0,0 +1,15 @@ +# ADR-0010: OAuth state lives in `@atproto/oauth-client-browser`; Zustand holds the UI bits + +`@atproto/oauth-client-browser` is the single source of truth for OAuth sessions — token storage, DPoP signing, refresh, key rotation. Bluepy does not duplicate or override this. + +A Zustand store (`src/state/sessions.ts`, with `persist` middleware) holds only: + +- `activeDid: string | null` +- `knownDids: string[]` +- `perAccountPrefs: Record` + +On boot, for each `knownDids` entry, Bluepy calls `oauthClient.restore(did)`; failures are surfaced and the offending DID is removed from `knownDids`. + +A `` (top-level, inside ``) reads the active session from the OAuth client (not Zustand) plus the active-AppView pref from Zustand, and instantiates the three clients (`pdsAgent`, `appviewAgent`, `bskyAppviewAgent`) for the active account, exposing them via React context. Data-layer hooks consume the clients through this context. + +**Active AppView is per-account**, not global. Alice can use Blacksky's AppView; Bob can use the official Bluesky one. Switching the active account swaps both the OAuth session and the active-AppView setting in lock-step. diff --git a/docs/adr/0011-render-pipeline.md b/docs/adr/0011-render-pipeline.md new file mode 100644 index 00000000..5551d495 --- /dev/null +++ b/docs/adr/0011-render-pipeline.md @@ -0,0 +1,9 @@ +# ADR-0011: Facet-text rendering and HTML sanitization stay outside the data layer + +`src/data/*.ts` deals only in lexicon types (`AppBskyFeedPost.Record`, `AppBskyActorDefs.ProfileViewDetailed`, etc.). It does not produce HTML. + +- **Facet-to-HTML**: `src/render/post-text.ts` exposes `renderPostText(text, facets) → string` — pure, no React, no DOM, easy to unit-test. Handles mentions (`@handle`), links (with `rel="nofollow noopener noreferrer"`), hashtags (``), escaping all interpolated text. +- **Sanitization**: `src/utils/sanitize-html.ts` keeps the existing DOMPurify wrappers (`sanitizePostHtml`, `sanitizeEmbedHtml`). XSS regression fixtures live next to it as today. +- **Rendering**: `` (or ``) glues the three: `renderPostText → sanitizePostHtml → dangerouslySetInnerHTML`. Used by status card, embed preview, notification snippet, search-result excerpt. + +Rejected: returning rendered HTML from the data layer (couples presentation with data, harder to test, harder to support multiple renderer surfaces). diff --git a/docs/adr/0012-intent-keyed-multi-draft.md b/docs/adr/0012-intent-keyed-multi-draft.md new file mode 100644 index 00000000..d96a6194 --- /dev/null +++ b/docs/adr/0012-intent-keyed-multi-draft.md @@ -0,0 +1,9 @@ +# ADR-0012: Compose drafts are intent-keyed (auto-multi-draft) + +The compose store holds a `Map` where `DraftKey` is a tuple of `{kind: 'top-level' | 'reply' | 'quote', targetUri?: AtUri}`. At most one draft per key. There is no drafts-list UI, no "save as draft" gesture; the keying is implicit. + +**Effect**: replying to Alice's post always picks up your last reply-to-Alice draft; composing top-level always picks up your last top-level draft; clicking quote on Bob always picks up your last quote-of-Bob draft. Submit clears that one key. A small badge on the compose entry can surface "N unfinished drafts" with a "clear all" button when N > 0. + +Rejected alternatives: **single global draft** (loses work when users switch context — the demand for "multi-draft" is mostly this one use case), **full multi-draft with explicit save + drafts-list UI** (right design eventually, wrong upfront cost — adds drawer UI, active-draft selector, stale-draft hygiene, URL semantics; +300 LOC of state and UI versus +100 LOC for intent-keyed). Full multi-draft remains a natural extension once intent-keying is in place. + +**Persistence boundary** (see ADR-0013): the draft `text`, `replyTo`, `quote`, `language`, `contentWarning`, `langs`, `threadgateConfig`, `postgateConfig` are persisted across reload by Zustand's `persist` middleware. Attachments (`File` objects + `URL.createObjectURL()` previews) are in-memory only. diff --git a/docs/adr/0013-upload-on-submit.md b/docs/adr/0013-upload-on-submit.md new file mode 100644 index 00000000..d5e9e1b2 --- /dev/null +++ b/docs/adr/0013-upload-on-submit.md @@ -0,0 +1,9 @@ +# ADR-0013: Blob upload happens at submit time, not on attach + +When a user attaches an image, video, or gif to a compose draft, the file is held in memory as a `File` object with a local preview via `URL.createObjectURL()`. **No upload to the PDS happens until the user clicks Post.** Submit uploads all blobs in parallel then `createRecord`s the post. + +Rejected alternative: **upload-on-attach** (snappier submit because blobs are already uploaded by the time the user posts). The blocker is structural: **ATProto has no `deleteBlob` endpoint**. Once a blob is uploaded, the only way it leaves the PDS is via implementation-defined orphan-blob GC (Bluesky's reference PDS uses something like a 24-hour window). This means an upload-on-attach client cannot honour a user's "actually, remove that photo" gesture cleanly — the blob persists on the PDS for hours regardless. For accidental picks of sensitive content (NSFW, private screenshots, wrong file), that is a privacy failure with no client-side mitigation. + +Upload-on-submit accepts the slower submit (parallelised, with progress UI) as the price of "removing an attachment in compose actually removes the upload". Submit retries within a single compose session **do** cache blob refs after a successful upload step, so retry-after-`createRecord`-failure is cheap. + +Acknowledged downside: this is the same UX regression that makes posting via the official Bluesky social-app feel slow. We accept it on privacy grounds. If ATProto ever ships a `deleteBlob`-or-equivalent procedure, this ADR is reopened. diff --git a/docs/adr/0014-all-or-nothing-submit.md b/docs/adr/0014-all-or-nothing-submit.md new file mode 100644 index 00000000..c26d7acf --- /dev/null +++ b/docs/adr/0014-all-or-nothing-submit.md @@ -0,0 +1,7 @@ +# ADR-0014: Compose submit is all-or-nothing + +When the user clicks Post, the submit pipeline is: validate → final facet detection (with cached mention DIDs) → upload all attachments in parallel → build embed (per precedence rules) → build post record → `createRecord` for the post. Any failure in any step aborts the submit; the draft is preserved with text, attachments, and refs intact; an inline error surfaces *where* the failure happened (specific blob upload failed, network timeout, rate limit, stale reply parent, etc.). + +Posts are atomic with respect to user intent: what the user composed is what gets sent. Partial success (post lands with 3 of 4 attachments because one upload failed) is the trap that bites every social-app user who realises post-hoc "wait, I didn't post the image I meant to". We do not do partial success. + +**Exception**: threadgate and postgate auxiliary records (`app.bsky.feed.threadgate`, `app.bsky.feed.postgate`) are created *after* the main post lands, in parallel. If either auxiliary create fails, the main post stays and a non-blocking warning surfaces ("Post created, but reply settings couldn't be applied — try again from the post menu"). The semantics are: the post is the load-bearing artifact; the gates are settings that can be retried out-of-band without re-posting. diff --git a/docs/adr/0015-compose-author-picker.md b/docs/adr/0015-compose-author-picker.md new file mode 100644 index 00000000..6ce0165e --- /dev/null +++ b/docs/adr/0015-compose-author-picker.md @@ -0,0 +1,7 @@ +# ADR-0015: Compose has an author picker for multi-account users + +When more than one account is logged in, the compose modal exposes a small avatar/dropdown letting the user choose which logged-in account this particular post is sent as. Selecting an author **does not** change the global active account — the next compose opens with the global active account again unless explicitly changed. + +Rejected alternative: **author = active account, always**. Multi-account users dashing off a side-account post would have to perform a global account switch (cache clear, re-render, etc.) just to post once. Phanpy-style per-compose author selection is what users expect from a multi-account client. + +Implementation: the compose store's `Draft` carries `authorDid: string` (defaults to global active DID when the draft is created). Submit uses `pdsAgent` for that DID — which means the compose modal needs access to a `pdsAgent` *per logged-in account*, not just the active one. `` (ADR-0010) instantiates clients for the active account by default; compose's submit path resolves the chosen author's `pdsAgent` on demand via the same client-construction helper. diff --git a/docs/plan/0001-data-layer-rebuild.md b/docs/plan/0001-data-layer-rebuild.md new file mode 100644 index 00000000..6ab7b286 --- /dev/null +++ b/docs/plan/0001-data-layer-rebuild.md @@ -0,0 +1,359 @@ +# Plan 0001 — Phase (b): Data-Layer Rebuild + +> **Audience**: an AFK coding agent. This document, `CONTEXT.md`, and ADRs 0001-0011 are the spec. +> **Strategy**: big-bang. Land in one branch. No incremental migration. No backwards-compatibility shims. + +## Goal + +Delete Bluepy's Mastodon-API-shaped adapter (`src/utils/atproto-adapter.ts` and everything that depends on its `v1`/`v2` namespaces, `AdaptedStatus`, `AdaptedAccount`, `AdaptedList`) and replace it with a thin Bluepy data layer built on top of `@atproto/api` + TanStack Query. The new layer surfaces ATProto operations as domain-verb functions + React hooks; types flowing through are `@atproto/api`'s generated lexicon types directly. Valtio (`src/utils/states.ts`) is removed; UI state moves to Zustand. The app's UI behaviour, routing, and visual surface stay identical. + +## Out of scope for this plan + +- **Compose flow specifics** — covered separately in plan 0002 (TBD). This plan defines the *seam* compose plugs into; the compose details are too rich to bundle here. +- **Component prop-signature cleanup** — Phase (c). This plan updates consumer call sites only to the extent required for them to compile against the new data layer (i.e. replacing `AdaptedStatus` field reads with `AppBskyFeedPost.Record`-shape field reads). +- **Library swap to atcute** — deferred per ADR-0002. Stay on `@atproto/api`. +- **Router migration to TanStack Router** — out of scope. +- **Suspense / SSR / TanStack Start** — explicitly not now. + +## Decisions referenced + +| Topic | ADR | +| --- | --- | +| At-URI canonical URL form | ADR-0001 | +| `@atproto/api` underneath (atcute deferred) | ADR-0002 | +| Domain verbs + hooks + lexicon types | ADR-0003 | +| Three-client dispatch (`pdsAgent`/`appviewAgent`/`bskyAppviewAgent`) | ADR-0004 | +| Mutations + selective optimistic updates | ADR-0005 | +| Zustand for UI state, Valtio removed | ADR-0006 | +| Hook-level `isLoading`/`error` (no Suspense) | ADR-0007 | +| Paginated hooks return flat shape | ADR-0008 | +| Cache keys via factory; account-scoped | ADR-0009 | +| Session lifecycle | ADR-0010 | +| Facet-text rendering layout | ADR-0011 | + +## Deletions + +These are removed in this phase. The agent does not migrate or shim them — they go away. + +- `src/utils/atproto-adapter.ts` (~3,884 LOC) — entire file. +- `src/utils/api.ts` — atproto client init + account hydration. Replaced by `src/data/clients.ts` + ``. +- `src/utils/states.ts` (~587 LOC) — Valtio global UI state. Replaced by Zustand stores under `src/state/`. +- `src/utils/store.ts` ↔ `src/utils/store-utils.ts` — the acknowledged structural cycle. Replaced by `src/state/sessions.ts` (Zustand + persist) for the parts that belong to UI state, and `@atproto/oauth-client-browser` for OAuth (it already owns it; we stop duplicating). +- `src/utils/auth-context.tsx` — replaced by `src/contexts/SessionProvider.tsx`. +- All exports named `AdaptedStatus`, `AdaptedAccount`, `AdaptedList`, `AdaptedNotification`, `AtprotoPost`, `AtprotoActor`, `AtprotoStrongRef`, `AtprotoReplyRefLike`, `AtprotoEmbedExternal`, etc. — the loose `Partial<>` wrappers introduced to bridge the Mastodon shape. Consumers move to `@atproto/api`'s lexicon types. +- The `v1`/`v2` namespace surface and any `api.v1.statuses.*` / `api.v2.accounts.*` call site. + +The Phanpy-era `phanpy.tsx` and component shells stay. The names that travel with them inside JSX (e.g. props called `status`) are renamed to `post` in Phase (c), not here — Phase (b) keeps the inside of components compiling, not pretty. + +## New file structure + +``` +src/ + data/ + clients.ts # Constructs pdsAgent / appviewAgent / bskyAppviewAgent for a session. + query-client.ts # TanStack QueryClient + default options (placeholderData: keepPreviousData, retry, gc). + keys.ts # Cache-key factory (ADR-0009). + _internal/ + use-infinite.ts # Shared helper that wraps useInfiniteQuery into the flat shape (ADR-0008). + posts.ts # Post reads + writes + engagement. + profiles.ts # Profile reads + edit + follow/mute/block. + feeds.ts # Timeline, generator feeds, custom feeds. + notifications.ts # Notifications list + mark-read. + lists.ts # User lists + list-member management. + feed-generators.ts # Discover / save / unsave feed generators. + search.ts # Search posts + actors + hashtags. + bookmarks.ts # Bookmark add/remove/list. + errors.ts # NotAuthenticatedError, AppViewNotSupportedError, others. + state/ + sessions.ts # Zustand: activeDid, knownDids, perAccountPrefs (incl. activeAppView). + compose.ts # Zustand: compose draft (text, attachments, reply ref, quote ref, language). + ui.ts # Zustand: modal stack, nav scroll, transient toggles, filter selections. + contexts/ + SessionProvider.tsx # Reads active session from OAuth client + active-AppView from Zustand, + # constructs the three clients, exposes via context. + render/ + post-text.ts # Pure function: renderPostText(text, facets) → string. Mentions, links, hashtags. + utils/ + sanitize-html.ts # Stays. DOMPurify wrappers (sanitizePostHtml, sanitizeEmbedHtml). +``` + +## Per-module specs + +### `src/data/clients.ts` + +Exports a factory called by ``: + +```ts +import { AtpAgent } from '@atproto/api'; +import type { OAuthSession } from '@atproto/oauth-client-browser'; + +export interface ClientBundle { + pdsAgent: AtpAgent | null; // null when logged out + appviewAgent: AtpAgent; // pinned to active AppView (per-account pref) + bskyAppviewAgent: AtpAgent; // pinned to public.api.bsky.app +} + +export function createClients(opts: { + session: OAuthSession | null; + activeAppViewService: string; // e.g. 'https://public.api.bsky.app' or Blacksky's URL +}): ClientBundle; +``` + +Constructor rules: +- `pdsAgent` is built from the OAuth session via `agent.sessionManager = …` (or whichever wiring `@atproto/api` uses with `@atproto/oauth-client-browser`'s `OAuthSession`). `null` if `session === null`. +- `appviewAgent` is a fresh `AtpAgent` pointed at `activeAppViewService`, unauthenticated by default. Authenticated calls go through `pdsAgent` with `configureProxy(...)` set to the active AppView's DID. +- `bskyAppviewAgent` is a fresh `AtpAgent` pointed at `https://public.api.bsky.app`, unauthenticated. + +The dispatch rule for every function in `src/data/*.ts`: + +- **Writes / identity-bound reads** → `pdsAgent`. Throw `NotAuthenticatedError` if `pdsAgent === null`. +- **`app.bsky.unspecced.*`, trending, discover-feeds, anything documented as not-portable** → `bskyAppviewAgent`. (Documented via comments at the function site.) +- **Everything else under `app.bsky.*`** → `appviewAgent`. + +### `src/data/query-client.ts` + +```ts +import { QueryClient } from '@tanstack/react-query'; + +export function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + placeholderData: (prev: unknown) => prev, // keepPreviousData (ADR-0007) + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + }, + }); +} +``` + +There is one `QueryClient` for the whole app (not per-account). Account isolation is via cache-key prefixing (ADR-0009). + +### `src/data/keys.ts` + +Exactly as ADR-0009. No additions during this phase; keys are added as new endpoints are introduced. + +### `src/data/_internal/use-infinite.ts` + +Generic helper that wraps `useInfiniteQuery` into the ADR-0008 flat shape: + +```ts +export function useInfiniteList(opts: { + queryKey: readonly unknown[]; + queryFn: (ctx: { pageParam?: string }) => Promise; + getCursor: (page: TPage) => string | undefined; + flatten: (page: TPage) => TItem[]; +}): { + items: TItem[]; + loadMore: () => void; + hasMore: boolean; + isLoadingMore: boolean; + isLoading: boolean; + error: Error | null; +} +``` + +Every paginated hook in `src/data/*.ts` is a thin wrapper around this. + +### `src/data/posts.ts` + +Function and hook exports (lexicon types throughout): + +```ts +// Reads +export function getPost(did: string, uri: AtUri): Promise; +export function getPostThread(did: string, uri: AtUri, depth?: number): Promise; + +export function usePost(uri: AtUri): { data?: AppBskyFeedDefs.PostView; isLoading; error }; +export function useThread(uri: AtUri, depth?: number): { data?: AppBskyFeedDefs.ThreadViewPost; isLoading; error }; + +// Writes (mutations) — ADR-0005 +export function useCreatePost(): UseMutationResult<...>; // wait-and-invalidate; broad invalidation +export function useDeletePost(): UseMutationResult<...>; // wait-and-invalidate; broad invalidation +export function useLikePost(): UseMutationResult<...>; // OPTIMISTIC +export function useUnlikePost(): UseMutationResult<...>; // OPTIMISTIC +export function useRepostPost(): UseMutationResult<...>; // OPTIMISTIC +export function useUnrepostPost(): UseMutationResult<...>; // OPTIMISTIC +export function useBookmarkPost(): UseMutationResult<...>; // OPTIMISTIC +export function useUnbookmarkPost(): UseMutationResult<...>; // OPTIMISTIC +``` + +### `src/data/profiles.ts` + +```ts +export function getProfile(did: string, actor: string): Promise; +export function useProfile(actor: string): { data; isLoading; error }; + +export function useEditProfile(): UseMutationResult<...>; // wait-and-invalidate +export function useFollowAccount(): UseMutationResult<...>; // OPTIMISTIC +export function useUnfollowAccount(): UseMutationResult<...>; // OPTIMISTIC +export function useMuteAccount(): UseMutationResult<...>; // OPTIMISTIC +export function useUnmuteAccount(): UseMutationResult<...>; // OPTIMISTIC +export function useBlockAccount(): UseMutationResult<...>; // OPTIMISTIC +export function useUnblockAccount(): UseMutationResult<...>; // OPTIMISTIC +``` + +### `src/data/feeds.ts` + +```ts +export function useTimelineFeed(): { items: PostView[]; loadMore; hasMore; isLoadingMore; isLoading; error }; +export function useProfileFeed(actor: string, filter?: 'posts' | 'posts-and-replies' | 'media'): {...}; +export function useGeneratorFeed(generator: AtUri): {...}; +export function useTrendingTopics(): {...}; // uses bskyAppviewAgent +``` + +### `src/data/notifications.ts`, `lists.ts`, `feed-generators.ts`, `search.ts`, `bookmarks.ts` + +Each follows the same pattern: imperative functions + read hooks via `useInfiniteList` or `useQuery` + mutation hooks for the writes that belong to that domain. Each file is its own complete spec — the agent looks at the lexicon for the relevant `app.bsky..*` endpoints and ports them under the convention. + +### `src/data/errors.ts` + +```ts +export class NotAuthenticatedError extends Error { … } +export class AppViewNotSupportedError extends Error { … } // raised when bskyAppviewAgent also fails +``` + +These are throwable inside `queryFn` / `mutationFn`. TanStack Query surfaces them via `error`. + +### `src/state/sessions.ts` + +```ts +interface SessionsState { + activeDid: string | null; + knownDids: string[]; + perAccountPrefs: Record; + // actions + setActive(did: string): void; + addKnown(did: string, prefs: SessionsState['perAccountPrefs'][string]): void; + removeKnown(did: string): void; + setActiveAppView(did: string, cfg: { service: string; proxyDid: string }): void; +} +``` + +Uses Zustand's `persist` middleware against `localStorage`. The persisted shape is exactly the four fields above. On boot, each `knownDids` entry is reconciled with `@atproto/oauth-client-browser` via `oauthClient.restore(did)`; failed restores are removed. + +### `src/state/compose.ts` + +Holds the active compose draft. Schema: + +```ts +interface ComposeState { + text: string; + attachments: ComposeAttachment[]; // images, video, animations — see plan 0002 + replyTo?: AtprotoStrongRef; // the parent post being replied to + quote?: AtprotoStrongRef; + language?: string; + contentWarning?: string; + // actions + setText(text: string): void; + addAttachment(att: ComposeAttachment): void; + removeAttachment(id: string): void; + setReply(ref?: AtprotoStrongRef): void; + setQuote(ref?: AtprotoStrongRef): void; + reset(): void; +} +``` + +Detailed compose mechanics (facet detection, blob upload, embed building) live in plan 0002. + +### `src/state/ui.ts` + +Holds modal stack, transient toggles, scroll/nav state. Schema TBD per concrete UI inventory; the agent inventories what `src/utils/states.ts` exposes today and maps each export into the appropriate Zustand slice. + +### `src/contexts/SessionProvider.tsx` + +```tsx +export function SessionProvider({ children }: { children: ReactNode }) { + const activeDid = useSessionsStore(s => s.activeDid); + const activeAppViewCfg = useSessionsStore(s => + activeDid ? s.perAccountPrefs[activeDid]?.activeAppView : null + ); + const session = useOAuthSession(activeDid); // hook that reads from @atproto/oauth-client-browser + + const clients = useMemo( + () => createClients({ session, activeAppViewService: activeAppViewCfg?.service ?? BSKY_APPVIEW_URL }), + [session, activeAppViewCfg?.service], + ); + + return {children}; +} + +export function useClients(): ClientBundle { … } +export function useActiveDid(): string | null { … } +``` + +### `src/render/post-text.ts` + +```ts +export function renderPostText(text: string, facets?: AppBskyRichtextFacet.Main[]): string; +``` + +Pure; no React; no DOM. Iterates facets in byte-offset order, escapes interpolated text, wraps mention/link/hashtag segments with the appropriate ``. Mentions link to the at-URI permalink form (ADR-0001). + +## Provider tree + +```tsx + + + + + + + +``` + +`` and below consume hooks from `src/data/*.ts`, which read `useClients()` + `useActiveDid()` internally. Components don't manipulate clients directly. + +## Consumer migration + +For each of the 60+ existing call sites that use `api.v1.*` / `api.v2.*` / `AdaptedStatus` / Valtio `useSnapshot`: + +- Replace `api.v1.statuses.fetch(id)` → `usePost(uri)` and read `data?.record.text` (not `data?.content`). +- Replace `api.v1.accounts.fetch(id)` → `useProfile(actor)` and read `data?.displayName` (not `data?.acct`). +- Replace Valtio `useSnapshot(states.modals.something)` → `useUIStore(s => s.modals.something)`. +- Replace `useSnapshot(states.composeDraft)` → `useComposeStore(s => s.text)` (etc.). + +A find/replace lookup table is appended at the end of this plan and grown as the agent encounters new patterns. + +## Acceptance criteria + +Phase (b) is "done" when **all** of these are true: + +1. `src/utils/atproto-adapter.ts` does not exist. +2. `src/utils/states.ts`, `store.ts`, `store-utils.ts`, `api.ts`, `auth-context.tsx` do not exist. +3. No file imports the symbols `AdaptedStatus`, `AdaptedAccount`, `AdaptedList`, `AdaptedNotification`. +4. `bun run typecheck` passes with zero errors. **No `as any` anywhere in code created or modified by this phase** — strictly enforced. New files under `src/data/`, `src/state/`, `src/contexts/`, `src/render/` must be entirely `as any`-free; consumer files updated as part of the port must have any pre-existing `as any` casts that touch the data-layer seam either removed or replaced with a typed assertion (e.g. a discriminated-union narrowing, a lexicon-type guard). Pre-existing `as any` casts in files untouched by this phase are not in scope. +5. `bunx oxlint .` passes; `bunx oxfmt --check .` passes. +6. `bun run test` passes the existing suite. Behavioural tests for timeline, status page, compose smoke, notifications, mute/block round-trip, like/repost optimistic round-trip, log-out-while-feed-cached are added in `tests/data-layer/`. +7. `bun run build` succeeds. (Bundle-size delta is *not* a phase-(b) gate — revisit after the rebuild lands.) +8. Logged-in walk-through against `bun run dev` (HTTPS-exposed): timeline loads, status page loads via at-URI permalink, compose post lands, like/unlike toggles optimistically, notifications list and mark-read, log-out + log-in works, account-switch swaps feeds without leaking the other account's cache. + +## Things to confirm before the agent runs + +These are the questions still open. Filled in by additional grilling passes: + +- [ ] Compose flow details — plan 0002 (next grilling pass). +- [ ] Specific list of operations that need `bskyAppviewAgent` (ADR-0004 says it's empirical; populate it as the agent encounters Blacksky/active-AppView gaps). +- [ ] Service-auth handling for video upload (`video.bsky.app` audience). +- [ ] Inventory of `src/utils/states.ts` exports → Zustand slice mapping. + +## Find/replace lookup (grows during port) + +| Old | New | +| --- | --- | +| `api.v1.statuses.fetch(id)` | `usePost(uri)` | +| `api.v1.accounts.fetch(id)` | `useProfile(actor)` | +| `api.v1.timelines.home.list()` | `useTimelineFeed()` | +| `api.v1.notifications.list()` | `useNotifications()` | +| `useSnapshot(states.composeDraft)` | `useComposeStore(s => …)` | +| `AdaptedStatus` (field reads) | `AppBskyFeedDefs.PostView` (see lexicon for field names) | +| `AdaptedAccount` (field reads) | `AppBskyActorDefs.ProfileViewDetailed` | +| ... | ... | + +(Grows as more patterns are encountered.) diff --git a/docs/plan/0002-compose-flow.md b/docs/plan/0002-compose-flow.md new file mode 100644 index 00000000..7452db16 --- /dev/null +++ b/docs/plan/0002-compose-flow.md @@ -0,0 +1,226 @@ +# Plan 0002 — Compose Flow + +> **Audience**: same AFK coding agent that runs plan 0001. This plan describes the compose subsystem in detail. +> **Depends on**: plan 0001 fully landed. Compose consumes `src/data/posts.ts`, `src/data/profiles.ts`, the three-client dispatch (ADR-0004), the session provider (ADR-0010), and the cache-key factory (ADR-0009). + +## Goal + +Replace the compose feature inside `src/pages/compose.tsx` / `src/components/compose/` (currently ~2,147 LOC + helpers) with a rebuilt compose subsystem that: + +- Reads / writes drafts from a Zustand store (`src/state/compose.ts`) +- Uses ATProto lexicon types end to end (no `AdaptedStatus`-shaped wrappers) +- Implements intent-keyed auto-multi-draft (ADR-0012) +- Uploads blobs at submit time only, with local previews (ADR-0013) +- Submits all-or-nothing for the main post; threadgate/postgate auxiliary records are warn-on-fail (ADR-0014) +- Supports a per-compose author picker for multi-account users (ADR-0015) +- Surfaces every step's progress and failure clearly + +## Out of scope for this plan + +- **Thread-writing mode** (composing N posts at once that chain by reply-ref): plan 0003, future. Bluepy users have the "reply to your own post" fallback for now. +- **Scheduled posts**: not in scope. +- **Polls**: ATProto has no native poll record; not in scope. +- **Custom hashtag autocomplete**: deferred (no Bluesky typeahead endpoint). + +## Decisions referenced + +| Topic | ADR | +| --- | --- | +| Intent-keyed multi-draft | ADR-0012 | +| Upload-on-submit | ADR-0013 | +| All-or-nothing submit pipeline | ADR-0014 | +| Author picker for multi-account | ADR-0015 | +| Three-client dispatch (the active draft's `authorDid` selects which `pdsAgent`) | ADR-0004 | +| Session lifecycle / per-account active AppView | ADR-0010 | +| Render layer separation (compose uses `` for the reply-context preview) | ADR-0011 | + +## State model: `src/state/compose.ts` + +```ts +type DraftKey = + | { kind: 'top-level'; authorDid: string } + | { kind: 'reply'; authorDid: string; targetUri: AtUri } + | { kind: 'quote'; authorDid: string; targetUri: AtUri }; + +type AttachmentState = + | { id: string; kind: 'image'; file: File; previewUrl: string; altText: string; aspectRatio: { width: number; height: number }; status: 'ready' | 'uploading' | 'failed'; error?: string } + | { id: string; kind: 'video'; file: File; previewUrl: string; altText: string; aspectRatio: { width: number; height: number }; status: 'ready' | 'uploading' | 'failed'; error?: string }; + // Video and image share the shape; the discriminator drives upload pipeline branching. + +interface Draft { + authorDid: string; + text: string; + attachments: AttachmentState[]; + replyTo?: { uri: AtUri; cid: string }; // strong ref to parent + replyRoot?: { uri: AtUri; cid: string }; // strong ref to thread root + quote?: { uri: AtUri; cid: string }; + externalCardDismissed: boolean; // sticky-dismiss flag for the auto link card + langs: string[]; // defaults to [navigator.language] + selfLabels: string[]; // 'sexual' | 'nudity' | 'graphic-media' | '!warn' | '!hide' + threadgate: ThreadgateConfig; // default: 'everyone' + postgate: PostgateConfig; // default: 'everyone' + mentionResolutions: Record; // handle -> DID cache for this draft + updatedAt: number; +} + +interface ComposeState { + drafts: Map; // DraftKeyString = JSON-encoded DraftKey + activeKey: DraftKeyString | null; // currently-open compose modal + + openDraft(key: DraftKey, seed?: Partial): void; // creates or activates + closeDraft(): void; // hides modal; draft persists + updateActive(patch: Partial): void; + addAttachment(att: Omit): void; + removeAttachment(id: string): void; + setAuthor(did: string): void; // see Q28a / ADR-0015 + resetActive(): void; // clears the active draft after successful submit + clearAll(): void; // "discard all drafts" power user action +} +``` + +**Persistence (Zustand `persist` middleware)**: +- Persisted fields: `drafts[*].text`, `replyTo`, `replyRoot`, `quote`, `externalCardDismissed`, `langs`, `selfLabels`, `threadgate`, `postgate`, `authorDid`, `updatedAt`. Plus `activeKey`. +- **Not persisted**: `drafts[*].attachments` (File objects can't survive reload), `mentionResolutions` (cheap to recompute). +- A custom `partialize` strips attachments on save and on load. + +## Sub-systems + +### Facet detection — `src/compose/facets.ts` + +- Debounced 300 ms after last keystroke (ADR-implied). +- Runs `RichText.detectFacets(agent)` using `appviewAgent` (mention resolution doesn't need write auth). +- For each `@handle` encountered, the resolved DID is stashed in `draft.mentionResolutions[handle]`. Resubmitting with the same draft re-uses the cache — no re-resolution. +- Cache cleared when the draft is reset or cleared. + +### Mentions autocomplete — `src/compose/mentions-autocomplete.tsx` + +- Triggers when the cursor is inside an `@` partial. +- Calls `useSearchActorsTypeahead(term)` (defined in `src/data/profiles.ts`) — hits `app.bsky.actor.searchActorsTypeahead` on `appviewAgent`, debounced 150 ms. +- Dropdown anchored at the cursor position; keyboard nav (↑ / ↓ / Enter / Esc). +- Selecting an actor: + - inserts `@handle.bsky.social ` at the cursor + - stashes the actor's DID in `draft.mentionResolutions[handle]` immediately, so facet detection doesn't re-resolve it + +### Link card — `src/compose/external-card.tsx` + +- Watches the draft text for URLs. The **last** URL in text is the candidate. +- If `externalCardDismissed === true`, no card is fetched or shown. +- Otherwise: fetch metadata from `https://cardyb.bsky.app/v1/extract?url=`. Cardyb returns `{ url, title, description, image, error? }`. Cache results per-URL in compose-store for the duration of the draft (don't re-fetch on every keystroke). +- Render the card preview with a `×` dismiss button. Clicking `×` sets `externalCardDismissed = true` for this draft and removes the card from the UI. The URL stays in the text as a plain link. +- If the user pastes a different URL later in the same draft and `externalCardDismissed === false`, the card updates to the new (last) URL. + +### Attachment management + +- **Add**: user picks file(s) → for each, generate id, build `previewUrl = URL.createObjectURL(file)`, validate size + dimensions (see below), push to `draft.attachments` with `status: 'ready'`. +- **Pre-validation on add**: + - **Image**: if `> 2 MB` or `long_edge > 4000 px`, run client-side compression (canvas resize + re-encode to JPEG/WebP). If still over limits, reject with inline error on that attachment. + - **Video**: enforce Bluesky's video constraints (size + duration + codec; see CLAUDE.md "Bluesky image limits" for the general policy and consult the official video upload constraints at submit time). + - **Count**: max 4 images, max 1 video, images and video are mutually exclusive. + - **Aspect ratio**: auto-derived from image natural dimensions; stored on the attachment. +- **Remove**: drop the attachment from `draft.attachments` and revoke `URL.revokeObjectURL(previewUrl)`. +- **No upload happens at this stage.** All `File` objects are held in memory. + +### Submit pipeline — `src/compose/submit.ts` + +Order of operations, all-or-nothing (ADR-0014): + +1. **Validate.** Text length ≤ 300 graphemes after facet substitution; at least text OR an attachment OR a quote; valid lang codes; valid threadgate / postgate config. +2. **Final facet detection.** Run `RichText.detectFacets()` once more, using `draft.mentionResolutions` to skip already-resolved mention lookups. If a mention can't be resolved, surface inline warning and ask the user to confirm posting anyway. +3. **Upload all attachments in parallel.** + - Each attachment goes to its appropriate audience: + - **Image**: `authorPdsAgent.com.atproto.repo.uploadBlob(file)` + - **Video**: service-auth-token-mediated upload to `https://video.bsky.app` (see "Video upload" below) + - Show per-attachment progress in the UI (state transitions: `ready` → `uploading` → `ready` with `blobRef` stashed). + - If any attachment upload fails, **abort the whole submit**. The attachment's `status` becomes `'failed'` with an `error` message; the rest of the draft stays intact. Submit retries don't re-upload attachments that already succeeded (blob refs are cached in the attachment state for the lifetime of the draft session). +4. **Build embed** (per precedence in Q25 + ADR-0011 dependencies): + - attachments + quote → `app.bsky.embed.recordWithMedia` (quote = `record`, attachments = `media`) + - attachments only → `app.bsky.embed.images` or `app.bsky.embed.video` + - quote only → `app.bsky.embed.record` + - URL in text + no attachments + no quote + `externalCardDismissed === false` → `app.bsky.embed.external`. The cardyb-supplied `image` is fetched and uploaded as a blob *here* (also through the author's PDS agent), then attached to the embed's `thumb` field. + - else: no embed +5. **Build post record.** `app.bsky.feed.post.Record` with `text`, `facets`, `embed?`, `reply?`, `langs`, `labels` (self-labels), `createdAt`. +6. **`createRecord`** the post on the author's PDS. Returns `{ uri, cid }`. +7. **Auxiliary records** (parallel, warn-on-fail): + - If `threadgate !== 'everyone'` (default): `createRecord` `app.bsky.feed.threadgate` with `post: post.uri` and the gate config. + - If `postgate !== 'everyone'` (default): `createRecord` `app.bsky.feed.postgate` with `post: post.uri` and the gate config. + - Either failure surfaces a non-blocking toast; the post stays. +8. **Cache invalidation** (per ADR-0005 broad-invalidation rule on `createPost`): + - Invalidate every `keys.feed(authorDid, ...)` and `keys.timeline(authorDid)`. + - If reply: also invalidate `keys.thread(authorDid, replyTo.uri)` and `keys.thread(authorDid, replyRoot.uri)`. + - If quote: invalidate `keys.post(authorDid, quote.uri)` (engagement count changed). +9. **Reset draft.** Remove the entry from `drafts[activeKey]`, set `activeKey = null`. + +### Video upload — `src/compose/video-upload.ts` + +Bluesky video uploads use service-auth tokens against `https://video.bsky.app`. Sketch: + +1. Call `authorPdsAgent.com.atproto.server.getServiceAuth({ aud: 'did:web:video.bsky.app', lxm: 'com.atproto.repo.uploadBlob' })` to mint a service-auth token. +2. Upload via `https://video.bsky.app/xrpc/app.bsky.video.uploadVideo` (or the current Bluesky endpoint — verify against the latest spec when porting) with the token as Bearer auth. +3. Poll `app.bsky.video.getJobStatus` until processing completes. +4. The resulting blob ref is what goes into `app.bsky.embed.video.video`. + +If the service-auth token expires mid-upload, mint a fresh one and retry. This is wrapped inside the attachment's submit-time upload step. + +### Author picker — `src/compose/author-picker.tsx` + +Renders only when `useSessionsStore(s => s.knownDids.length > 1)`. Avatar + handle of the current draft author with a dropdown to switch. Selecting an author: + +- Calls `compose.setAuthor(newDid)` which **forks the draft**: the previous draft (under the old `DraftKey` with `authorDid: oldDid`) is preserved; a new draft is created for `authorDid: newDid` with the same text/attachments/refs *copied*. This avoids a quietly-changed `authorDid` on a draft the user thought belonged to a different account. +- Submit uses the author's `pdsAgent`. `` exposes a helper `getPdsAgentFor(did)` that constructs a `pdsAgent` for any logged-in DID, not just the active one. + +## Files created / modified + +``` +src/ + state/ + compose.ts # Zustand store (above) + compose/ + facets.ts # debounced detectFacets + mention DID caching + mentions-autocomplete.tsx + external-card.tsx # cardyb fetch + dismiss UI + submit.ts # the submit pipeline + video-upload.ts # service-auth video upload + author-picker.tsx # multi-account author selector + pages/ + compose.tsx # rebuilt against the new store + submit + components/ + compose/ # rebuilt against the new store + submit +``` + +## Things deleted + +- Existing `src/utils/states.ts` compose-related state (lives in plan 0001's delete list). +- `src/utils/atproto-adapter.ts`'s compose helpers (`uploadComposeMediaAttachments`, `createAtprotoExternalEmbed`, the `agent.post(...)` convenience, `richTextToHTML` insofar as it's used by the old compose modal — note `richTextToHTML` itself becomes `renderPostText` in `src/render/post-text.ts` per ADR-0011). + +## Acceptance criteria + +Phase (b) compose is done when **all** of these are true: + +1. New compose subsystem exists in the file structure above; old `src/pages/compose.tsx` is rebuilt to use it. +2. `bun run typecheck` passes with zero errors. **No `as any` in any compose code.** +3. `bunx oxlint src/state/compose.ts src/compose/ src/pages/compose.tsx` passes. +4. Behavioural Playwright tests in `tests/compose/`: + - Compose top-level → text + 1 image → submit lands the post, attachment is visible in the resulting record. + - Compose reply → reply ref matches `parent.uri/cid`, `root.uri/cid` resolves correctly when parent is itself a reply. + - Compose quote → quote ref matches. + - Compose with attachments + quote → resulting embed is `recordWithMedia`. + - Intent-keyed multi-draft: open top-level draft, type X, open reply-to-Alice draft, type Y, back to top-level — X is still there. + - Author picker: with two accounts logged in, switch author in compose, submit — post appears under the chosen author, not the global active account. + - Submit failure on attachment → draft preserved with `failed` attachment marker, retry works. + - Threadgate set to "followed-only" → main post lands, gate record lands, both visible in the resulting thread settings. + - Threadgate failure (force-fail in test by mocking) → main post lands, warning toast surfaces, post stays. + - External link card: paste a URL → card preview renders from cardyb → dismiss `×` → card gone → submit lands post with URL as plain link, no `external` embed. +5. Logged-in walk-through against `bun run dev`: + - Type `@al` → typeahead surfaces matching actors → select inserts handle + caches DID. + - Type a URL → 300 ms later the link card preview appears. + - Add 4 images → try to add a 5th → rejected with clear error. + - Add an image, try to add a video → rejected with mutually-exclusive error. + - Drop a 8 MB photo → client-side compression succeeds → attachment shows with reasonable file size. + - Submit a reply where the parent was deleted between cache and submit → submit fails with clear error, draft retained. +6. Persistence walk-through: write a draft with attachments → close compose modal (don't submit) → reopen → text + reply ref + quote ref + language + threadgate config still there; attachments gone (acceptable per ADR-0013 rationale). + +## Open items + +- [ ] Confirm current Bluesky video upload API specifics (endpoint, JSON shape) against `~/social-app` before the agent runs. Spec drift is real here. +- [ ] If Bluesky changes the threadgate/postgate lexicon (still under iteration), update the gate-config shapes accordingly. +- [ ] Thread-writing mode → plan 0003. -- 2.51.2