Composable Atmosphere UserInput #
Composable Atmosphere UserInput is a Swift package for building UserInput.app-shaped feedback boards on ATProto.
It provides typed app.userinput.* records and repository operations, reusable board and thread projections, and a self-loading SwiftUI/TCA feedback-board feature. A host app supplies the board identity, composes the package's OAuth manifest component, and presents authorization through its existing authentication stack.
The package is intentionally product-neutral.
Products #
ComposableAtmosphereUserInputcontains collection identifiers, record models, projections, authorization requirements, and typedXRPC.Clientrepository helpers.ComposableAtmosphereUserInputUIcontains the board and discussion TCA features, SwiftUI views, AppView projection client, and actor-profile lookup dependency.
The supported platforms are iOS 17 or newer and macOS 14 or newer.
Installation #
Add the package to Package.swift:
.package(
url: "https://source.woody.fm/composable-atmosphere-userinput",
branch: "main"
)
Add the model product wherever records or authorization are used:
.product(
name: "ComposableAtmosphereUserInput",
package: "composable-atmosphere-userinput"
)
Apps presenting the board should also add the UI product:
.product(
name: "ComposableAtmosphereUserInputUI",
package: "composable-atmosphere-userinput"
)
Host-App Integration #
A host app has four responsibilities:
- Compose the package's authorization component into its app manifest.
- Provide the AT URI of an
app.userinput.spacerecord. - Mount
UserInputFeedbackBoardinside its navigation and authentication stacks. - Present
UserInputFeedbackBoardOperationFailedthrough its app-wide error UI.
The package does not create an OAuth session store, choose an account, or present sign-in and permission-upgrade UI.
Compose Authorization #
Add UserInputAuthorization.appManifestComponent to the host's manifest:
import ATProtoOAuth
import ComposableAtmosphereUserInput
let appManifest = OAuth.AppManifest(
appID: appConfiguration.appID,
appName: "Example App",
redirectURIs: [appConfiguration.redirectURI],
basePermissions: OAuth.GrantedPermissions([.atproto]),
components: [UserInputAuthorization.appManifestComponent]
)
The component registers include:app.userinput.authFull for UserInput record operations and blob:image/* for image attachments. The host must publish OAuth client metadata generated from the same composed manifest.
Repository writes use UserInputAuthorization.authFullRequirement by default. When the selected session is absent or does not satisfy that requirement, Composable Atmosphere reports an authorization requirement to the host authentication stack.
Configure a Board #
Board configuration is deliberately small. It identifies one space record and can include a browser fallback:
import ATProto
import ComposableAtmosphereUserInput
import ComposableAtmosphereUserInputUI
guard let spaceURI = ATProto.URI(
"at://did:plc:example/app.userinput.space/3example"
) else {
fatalError("Invalid UserInput space URI")
}
let feedbackBoardConfiguration = UserInputFeedbackBoardConfiguration(
space: UserInputRecordReference(uri: spaceURI),
externalURL: URL(
string: "https://userinput.app/s/did:plc:example/3example"
)
)
Tags, statuses, moderators, pinned discussions, and moderation state come from UserInput records or the AppView projection. They are not duplicated in host-app configuration.
Mount the Feature #
Inject configuration at the feature composition boundary:
.ifLet(\.destination, action: \.destination) {
Destination.body
.transformDependency(\.userInputFeedbackBoardConfiguration) {
$0 = feedbackBoardConfiguration
}
}
Create the destination with only feature state:
state.destination = .feedbackBoard(
UserInputFeedbackBoard.State()
)
Present UserInputFeedbackBoardView from the host's existing NavigationStack. The board feature owns its discussion composer and discussion-detail navigation.
The live client also expects the host authentication composition to provide Composable Atmosphere's authenticated XRPC.Client, getDefaultSession, and getOptionalDefaultSession dependencies. These should flow from the app's normal dependency tree; they should not be copied into feature state or recovered from process globals.
Apps that already maintain a Bluesky actor cache can replace userInputActorProfileLookup at the same feature-composition boundary. The default live lookup is useful for standalone adoption, but it should not create a second profile cache inside a host that already owns one.
Present Errors #
Operation errors are ephemeral. The feature posts UserInputFeedbackBoardOperationFailed instead of retaining an error payload in state.
Hosts can listen at their normal error-presentation boundary. The integration point is:
.onEvent(UserInputFeedbackBoardOperationFailed.self) { failure, _ in
// Translate failure.operation and failure.underlyingError into the
// host application's error presentation.
}
failure.operation identifies the failed action:
loadBoardloadDiscussioncreateDiscussioncreateReplytoggleUpvotedeleteDiscussiondeleteReply
Current UI Behavior #
UserInputFeedbackBoard currently supports:
- loading board metadata and all indexed discussions
- loading a discussion thread and its replies
- displaying owner, moderator, author, tag, status, pin, lock, and vote information
- filtering hidden discussions, hidden replies, and banned authors from the visible UI
- creating discussions using the space's configured tags
- adding replies to unlocked discussions
- adding or removing upvotes on discussions and replies
- deleting discussions and replies owned by the selected account
- opening a configured external URL when the native board cannot load
The package models additional UserInput records, including downvotes, membership, status, pin, hide, ban, lock, edit, notification-check, and visit records. Moderator controls, search, sorting controls, status editing, and image selection are not yet exposed by the reusable UI.
Live Data Flow #
The live UserInputFeedbackBoardClient keeps reads and writes on their appropriate services:
- Board and thread projections are read from the UserInput.app AppView.
- Viewer upvote state is read from the selected account's PDS.
- Discussions, replies, votes, and deletions are written to the selected account's PDS through typed
XRPC.Clientrepository methods. - Actor profiles are loaded through
UserInputActorProfileLookupClient; its live value uses Bluesky's public actor profile API. - Deletion methods verify that the selected account owns the record before issuing a repository delete.
The AppView HTTP response types are internal. Consumers work with UserInputBoardProjection, UserInputDiscussionProjection, UserInputThreadProjection, and UserInputReplyProjection instead of coupling app code to the AppView's JSON shape.
Repository API #
The model product adds typed methods to XRPC.Client:
userInputGetRecorduserInputListRecordsuserInputCreateRecorduserInputPutRecorduserInputDeleteRecorduserInputUploadBlob
Each operation accepts a typed UserInputRecordCollection<RecordValue>, so a record value cannot accidentally be sent to the wrong UserInput collection:
let output = try await xrpcClient.userInputCreateRecord(
repo: .did(session.accountID.rawValue),
collection: .discussion,
record: discussionRecord
)
Use these repository methods when building another UserInput workflow without the reusable board UI.
Replacing the UI Client #
UserInputFeedbackBoardClient is a method-shaped dependency. Consumers can replace only the behavior needed by a test, preview, or another AppView adapter:
withDependencies {
$0.userInputFeedbackBoardClient = UserInputFeedbackBoardClient(
loadBoard: { configuration in
boardProjection
},
loadDiscussion: { discussion in
threadProjection
},
toggleUpvote: { reference, wasUpvoted, createdAt in
!wasUpvoted
}
)
} operation: {
// Construct the feature store here.
}
Unspecified methods fail with UserInputFeedbackBoardError.unconfiguredClient. Tests therefore declare the operations their story actually exercises instead of routing actions through a general mutation enum.
Development #
Run the package checks with:
swift test
The UI test suite includes black-box stories for loading a board, posting a discussion, retrying failed loads, and interacting with a discussion through replies, votes, and author-owned deletion.