diff --git a/.oxlintrc.json b/.oxlintrc.json index f30aee6d8..13f1411c4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -105,14 +105,6 @@ "foldkit/wrap-child-output-in-got-message": "off" } }, - { - // ui/aria ports React Aria's document-wide state (global input modality, scroll locks, the live - // announcer) as module state on purpose; see docs/foldkit-decisions/s5-primitives.md. - "files": ["apps/web-foldkit/src/ui/aria/**"], - "rules": { - "foldkit/no-module-level-mutable-state": "off" - } - }, { // Framework-free modules carried over from the React app: imperative singletons and // helpers, not Foldkit programs, so the Foldkit architecture rules do not apply. diff --git a/CLAUDE.md b/CLAUDE.md index 078ddf40e..9a4286ebc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a monorepo with the following structure: - `apps/web-foldkit/` - Web frontend built with Foldkit (Effect-based Elm architecture), Vite and TailwindCSS. Also the desktop app's webview -- `apps/backendv2/` - Backend API using Bun runtime and Effect-TS +- `apps/backend/` - Backend API and RPC (Effect-TS); runs on Bun locally and as a Cloudflare Worker - `apps/cluster/` - Effect Cluster service for distributed workflows and background jobs - `packages/db/` - Shared database package: effect-orm table definitions, the `Database` service, and migrations - `packages/domain/` - Shared domain types, RPC contracts, and cluster definitions @@ -80,10 +80,12 @@ When working with Effect, Effect Atom, or TanStack DB, refer to these directorie - `bun run typecheck` - TypeScript checking without emitting files - `bun run test` - Run the app's Vitest suite -### Backend (apps/backendv2) +### Backend (apps/backend) - `bun run dev` - Start backend with hot reload using Bun (DO NOT USE - already running) -- `bun run typecheck` - TypeScript checking +- `bun run typecheck` - TypeScript checking (Bun entry and the Worker's `tsconfig.worker.json`) +- `bun run test` - Run the backend's Vitest suite (Postgres-backed tests start a Docker container; set `AUTH_TEST_DATABASE_URL` to use an existing database) +- `bun run test:sync` / `bun run test:policies` - Chat-sync and outbox, or policy, tests only ### Cluster (apps/cluster) @@ -115,7 +117,7 @@ When working with Effect, Effect Atom, or TanStack DB, refer to these directorie - **Runtime**: Bun - **Framework**: Effect-TS for functional programming - **Database**: PostgreSQL with effect-orm (`@maple-dev/effect-orm`) over `@effect/sql-pg` -- **Auth**: Clerk (`@clerk/backend` JWT verification + webhook sync) +- **Auth**: Hazel auth (`@hazel/auth`, on `@yielded/auth`): GitHub/Google sign-in, passkeys, cookie sessions; Clerk is only used to verify Maple's OAuth tokens (`services/oauth-bearer-auth.ts`) - **API**: RPC-style endpoints via Effect Http Api ### Cluster Service @@ -212,7 +214,7 @@ yield * - Backend follows Effect-TS patterns for error handling and dependency injection - Real-time features implemented via Cloudflare Realtimekit -- Authentication handled by Clerk; backend verifies Clerk-issued JWTs and syncs users/orgs via webhook +- Authentication handled by Hazel auth (`packages/auth`); the RPC and HttpApi middlewares resolve the session cookie, a session bearer credential (native clients) or a bot token to `CurrentUser` - Shared database package ensures type safety between frontend and backend - Domain package (`packages/domain/`) contains shared contracts: - RPC definitions for client-server communication diff --git a/apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl b/apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl index 2116e6f20..b527604d9 100644 --- a/apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl +++ b/apps/backend/.artifacts/chat-sync/chat-sync-diagnostics.jsonl @@ -9,3 +9,6 @@ {"timestamp":"2026-06-23T01:00:51.380Z","suite":"discord-gateway-dispatch","testCase":"message-create-routing","workerMethod":"ingestMessageCreate","action":"dispatch","dedupeKey":"discord:gateway:create:223456789012345678","syncConnectionId":"00000000-0000-4000-8000-000000000001","expected":"one inbound dispatch for both-direction link","actual":"1 dispatches"} {"timestamp":"2026-06-23T01:00:58.637Z","suite":"chat-sync-core-worker.integration","testCase":"hazel-message-create-update-delete","workerMethod":"syncHazelMessageToProvider","action":"create","syncConnectionId":"f280ff9c-f164-4a1f-974f-baa1d94a2911","expected":"synced","actual":"synced"} {"timestamp":"2026-06-23T01:01:00.427Z","suite":"chat-sync-core-worker.integration","testCase":"direction-inactive-webhook-origin-guards","workerMethod":"syncHazelMessageCreateToAllConnections","action":"direction_gate","expected":"1 synced","actual":"1 synced"} +{"timestamp":"2026-10-15T13:52:47.847Z","suite":"discord-gateway-dispatch","testCase":"message-create-routing","workerMethod":"ingestMessageCreate","action":"dispatch","dedupeKey":"discord:gateway:create:223456789012345678","syncConnectionId":"00000000-0000-4000-8000-000000000001","expected":"one inbound dispatch for both-direction link","actual":"1 dispatches"} +{"timestamp":"2026-10-15T13:52:53.829Z","suite":"chat-sync-core-worker.integration","testCase":"hazel-message-create-update-delete","workerMethod":"syncHazelMessageToProvider","action":"create","syncConnectionId":"c52e9350-a742-464a-b6df-1722eb33157e","expected":"synced","actual":"synced"} +{"timestamp":"2026-10-15T13:52:55.547Z","suite":"chat-sync-core-worker.integration","testCase":"direction-inactive-webhook-origin-guards","workerMethod":"syncHazelMessageCreateToAllConnections","action":"direction_gate","expected":"1 synced","actual":"1 synced"} diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 26c5ba5c6..0df02b15e 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -9,13 +9,10 @@ DATABASE_URL=postgresql://user:password@localhost:5432/app # Local cluster service (Docker) CLUSTER_URL=http://localhost:3020 -# Clerk Authentication -# Get these from: https://dashboard.clerk.com/ → API Keys -CLERK_SECRET_KEY=sk_test_your_clerk_secret_here -CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_here -CLERK_WEBHOOK_SECRET=whsec_your_clerk_webhook_secret_here +# Optional: verifies Maple's Clerk-issued OAuth tokens for the public API v1 (the last Clerk use). +# CLERK_SECRET_KEY=sk_test_your_clerk_secret_here -# Hazel sign-in (replacing Clerk). Off until AUTH_GITHUB_CLIENT_ID is set. +# Hazel sign-in. Off until AUTH_GITHUB_CLIENT_ID is set. # GitHub OAuth App callback URL: {API_BASE_URL}/auth/github/callback # Google OAuth client redirect URI: {API_BASE_URL}/auth/google/callback # Generate the keys with: bun run --cwd packages/auth generate-keys diff --git a/apps/backend/README.md b/apps/backend/README.md index 65152177e..04ca61afd 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -12,7 +12,7 @@ See the [root README](../../README.md) for development setup instructions. - **Effect-TS** for functional programming and dependency injection - **Effect RPC** for type-safe APIs - **Drizzle ORM** with PostgreSQL -- **Clerk** for authentication +- **Hazel auth** (`@hazel/auth`) for sign-in and sessions ## Development diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index d32b8dad0..0943ae661 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -40,13 +40,12 @@ import { TypingIndicatorRepo, UserPresenceStatusRepo, UserRepo, - ClerkSync, } from "@hazel/backend-core" -import { ClerkClient } from "@hazel/auth" import { GitHub } from "@hazel/integrations" import { Layer } from "effect" import { HazelApi } from "./api" import { HttpApiRoutes } from "./http" +import { ServerFailureReporterLive } from "./lib/error-reporter" import { AttachmentPolicy } from "./policies/attachment-policy" import { BotPolicy } from "./policies/bot-policy" import { InvitationPolicy } from "./policies/invitation-policy" @@ -81,7 +80,6 @@ import { ObjectStorage } from "./services/object-storage" import { OAuthProviderRegistry } from "./services/oauth" import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./services/hazel-auth" import { HazelSession } from "./services/hazel-session" -import { SessionManager } from "./services/session-manager" import { WebhookBotService } from "./services/webhook-bot-service" import { BotGatewayService } from "./services/bot-gateway-service" import { ChannelAccessSyncService } from "./services/channel-access-sync" @@ -129,6 +127,8 @@ export const AllRoutes = Layer.mergeAll( credentials: true, }), ), + // RpcServer and HttpApiBuilder report failed requests here; server failures get logged. + Layer.provide(ServerFailureReporterLive), ) /** @@ -208,8 +208,6 @@ export const AppServicesLive = Layer.mergeAll( RepoLive, PolicyLive, MockDataGenerator.layer, - ClerkClient.layer, - ClerkSync.layer, ObjectStorage.layer, GitHub.GitHubAppJWTService.layer, GitHub.GitHubApiClient.layer, @@ -224,8 +222,6 @@ export const AppServicesLive = Layer.mergeAll( ChannelAccessSyncService.layer, ConnectConversationService.layer, Invitations.layer, - // SessionManager.layer includes BackendAuth.layer via dependencies - SessionManager.layer, OAuthBearerAuth.layer, ).pipe(Layer.provideMerge(FetchHttpClient.layer)) @@ -234,7 +230,4 @@ export const AppServicesLive = Layer.mergeAll( * HTTP entry points build this; the Durable Objects never authenticate. Requires `Database` * and the auth SQL connection from the platform. */ -export const AppAuthorizationLive = AuthorizationLive.pipe( - Layer.provideMerge(SessionManager.layer), - Layer.provideMerge(HazelAuthLive), -) +export const AppAuthorizationLive = AuthorizationLive.pipe(Layer.provideMerge(HazelAuthLive)) diff --git a/apps/backend/src/lib/create-transactionId.ts b/apps/backend/src/lib/create-transactionId.ts index 552587143..ac16cc37e 100644 --- a/apps/backend/src/lib/create-transactionId.ts +++ b/apps/backend/src/lib/create-transactionId.ts @@ -7,17 +7,10 @@ const TxidRow = Schema.Struct({ txid: Schema.String }) /** * The id of the open transaction, for Electric to confirm the client's optimistic write against. * Must run inside `db.transaction`, which pins this statement to the transaction's connection. + * Fails with the typed DatabaseError / SchemaError, which callers remap with `withRemapDbErrors`. */ export const generateTransactionId = Effect.fn("generateTransactionId")(function* () { const db = yield* Database.Database - const rows = yield* db - .query(Database.sql`SELECT pg_current_xact_id()::xid::text AS txid`, TxidRow) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.die(`Database error generating transaction ID: ${err}`), - ), - ) - return yield* Schema.decodeEffect(TransactionIdFromString)(rows[0]?.txid ?? "").pipe( - Effect.catchTag("SchemaError", (err) => Effect.die(`Failed to parse transaction ID: ${err}`)), - ) + const rows = yield* db.query(Database.sql`SELECT pg_current_xact_id()::xid::text AS txid`, TxidRow) + return yield* Schema.decodeEffect(TransactionIdFromString)(rows[0]?.txid ?? "") }, Database.requireTransaction) diff --git a/apps/backend/src/lib/error-reporter.test.ts b/apps/backend/src/lib/error-reporter.test.ts new file mode 100644 index 000000000..abd036ac2 --- /dev/null +++ b/apps/backend/src/lib/error-reporter.test.ts @@ -0,0 +1,60 @@ +import { NodeHttpPlatform, NodeServices } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { InternalServerError } from "@hazel/domain" +import { Cause, Context, Effect, Layer, Logger, Schema } from "effect" +import { Etag, HttpRouter } from "effect/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/http-api" +import { ServerFailureReporterLive } from "./error-reporter" + +class ProbeGroup extends HttpApiGroup.make("probe").add( + HttpApiEndpoint.get("boom", "/boom", { success: Schema.String, error: InternalServerError }), +) {} + +const api = HttpApi.make("Probe").add(ProbeGroup) + +const driverText = 'relation "users" does not exist' + +const ProbeLive = HttpApiBuilder.group(api, "probe", (handlers) => + handlers.handle("boom", () => + Effect.fail( + new InternalServerError({ message: "Error selecting user", cause: new Error(driverText) }), + ), + ), +) + +const serve = (logged: Array) => { + const captureLogger = Logger.make(({ cause }) => { + logged.push(Cause.pretty(cause)) + }) + const appLayer = HttpApiBuilder.layer(api).pipe( + Layer.provide(ProbeLive), + Layer.provide(ServerFailureReporterLive), + Layer.provide(Logger.layer([captureLogger])), + Layer.provideMerge(HttpRouter.layer), + Layer.provideMerge(Etag.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpPlatform.layer), + ) + return HttpRouter.toWebHandler(appLayer as never, { disableLogger: true }) +} + +describe("ServerFailureReporterLive", () => { + it("logs an InternalServerError with its cause, and keeps the cause out of the response", async () => { + const logged: Array = [] + const { handler, dispose } = serve(logged) + try { + const response = await handler( + new Request("http://localhost/boom"), + Context.empty() as Context.Context, + ) + const body = await response.text() + + expect(response.status).toBe(500) + expect(body).toContain("Error selecting user") + expect(body).not.toContain(driverText) + expect(logged.some((line) => line.includes(driverText))).toBe(true) + } finally { + await dispose() + } + }) +}) diff --git a/apps/backend/src/lib/error-reporter.ts b/apps/backend/src/lib/error-reporter.ts new file mode 100644 index 000000000..e163e6196 --- /dev/null +++ b/apps/backend/src/lib/error-reporter.ts @@ -0,0 +1,40 @@ +import { type Cause, Effect, ErrorReporter, Predicate } from "effect" + +/** + * Failures that mean the server, not the caller, went wrong. Their client-facing form is a + * stable message; the underlying `cause` (kept off the wire) is what this reporter logs. + * `SessionLoadError` is left out: the auth middlewares log its cause where they raise it. + */ +const SERVER_FAILURE_TAGS: ReadonlySet = new Set([ + "InternalServerError", + "WorkflowInitializationError", + "WorkflowServiceUnavailableError", + "UploadError", + // Escaped the remapping to InternalServerError; still a server failure. + "DatabaseError", + "SchemaError", +]) + +const isServerFailure = (reason: Cause.Reason): boolean => + reason._tag === "Die" || + (reason._tag === "Fail" && + Predicate.hasProperty(reason.error, "_tag") && + typeof reason.error._tag === "string" && + SERVER_FAILURE_TAGS.has(reason.error._tag)) + +/** + * Logs every RPC and HttpApi failure that is the server's fault (5xx tags and defects) with its + * full `Cause`, including the `cause` chain clients never see. Expected 4xx failures are not + * logged. Both RpcServer and HttpApiBuilder report failed requests to `CurrentErrorReporters`. + */ +const serverFailureReporter: ErrorReporter.ErrorReporter = { + [ErrorReporter.TypeId]: ErrorReporter.TypeId, + report: ({ cause, fiber }) => { + if (!cause.reasons.some(isServerFailure)) return + Effect.runSyncWith(fiber.context)(Effect.logError("Request failed", cause)) + }, +} + +export const ServerFailureReporterLive = ErrorReporter.layer([serverFailureReporter], { + mergeWithExisting: true, +}) diff --git a/apps/backend/src/lib/policy-utils.test.ts b/apps/backend/src/lib/policy-utils.test.ts index 0e8f9c79b..58c587f31 100644 --- a/apps/backend/src/lib/policy-utils.test.ts +++ b/apps/backend/src/lib/policy-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest" -import { UnauthorizedError } from "@hazel/domain" +import { InternalServerError, UnauthorizedError } from "@hazel/domain" import { Effect, Result } from "effect" import { makePolicy, withPolicyUnauthorized } from "./policy-utils.ts" import { makeActor } from "../policies/policy-test-helpers.ts" @@ -37,7 +37,7 @@ describe("policy-utils", () => { it("maps non-unauthorized errors to UnauthorizedError", async () => { const authorize = makePolicy("Widget") const result = await Effect.runPromise( - authorize("read", () => Effect.fail({ _tag: "DatabaseError" as const })).pipe( + authorize("read", () => Effect.fail({ _tag: "EntityNotFound" as const })).pipe( Effect.provideService(CurrentUser.Context, makeActor()), Effect.result, ), @@ -48,6 +48,21 @@ describe("policy-utils", () => { expect(UnauthorizedError.is(result.failure)).toBe(true) } }) + + it("maps database failures to InternalServerError, never a 401", async () => { + const authorize = makePolicy("Widget") + const result = await Effect.runPromise( + authorize("read", () => Effect.fail({ _tag: "DatabaseError" as const })).pipe( + Effect.provideService(CurrentUser.Context, makeActor()), + Effect.result, + ), + ) + + expect(Result.isFailure(result)).toBe(true) + if (Result.isFailure(result)) { + expect(InternalServerError.is(result.failure)).toBe(true) + } + }) }) describe("withPolicyUnauthorized", () => { diff --git a/apps/backend/src/routes/api-v1/integrations.http.ts b/apps/backend/src/routes/api-v1/integrations.http.ts index 4cfcdc518..194d43179 100644 --- a/apps/backend/src/routes/api-v1/integrations.http.ts +++ b/apps/backend/src/routes/api-v1/integrations.http.ts @@ -93,7 +93,7 @@ export const HttpApiV1IntegrationsLive = HttpApiBuilder.group(HazelApi, "api-v1- Effect.fail( new InternalServerError({ message: "Failed to list organizations", - detail: String(err), + cause: err, }), ), ), @@ -120,7 +120,7 @@ export const HttpApiV1IntegrationsLive = HttpApiBuilder.group(HazelApi, "api-v1- Effect.fail( new InternalServerError({ message: "Failed to verify organization membership", - detail: String(err), + cause: err, }), ), ), @@ -149,7 +149,7 @@ export const HttpApiV1IntegrationsLive = HttpApiBuilder.group(HazelApi, "api-v1- Effect.fail( new InternalServerError({ message: "Failed to list channels", - detail: String(err), + cause: err, }), ), ), @@ -240,14 +240,14 @@ export const HttpApiV1IntegrationsLive = HttpApiBuilder.group(HazelApi, "api-v1- Effect.fail( new InternalServerError({ message: "Failed to create channel webhook", - detail: String(err), + cause: err, }), ), SchemaError: (err) => Effect.fail( new InternalServerError({ message: "Schema validation failed", - detail: String(err), + cause: err, }), ), }), diff --git a/apps/backend/src/routes/api-v1/messages.http.ts b/apps/backend/src/routes/api-v1/messages.http.ts index 4fd739da6..e202bc8d9 100644 --- a/apps/backend/src/routes/api-v1/messages.http.ts +++ b/apps/backend/src/routes/api-v1/messages.http.ts @@ -201,7 +201,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag Effect.fail( new InternalServerError({ message: "Database error while listing messages", - detail: String(err), + cause: err, }), ), ), @@ -291,7 +291,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(err), + cause: err, }), ), ), @@ -358,7 +358,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag Effect.fail( new InternalServerError({ message: "Database error while updating message", - detail: String(err), + cause: err, }), ), ), @@ -427,7 +427,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag Effect.fail( new InternalServerError({ message: "Database error while deleting message", - detail: String(err), + cause: err, }), ), ), @@ -534,7 +534,7 @@ export const HttpMessagesApiLive = HttpApiBuilder.group(HazelApi, "api-v1-messag Effect.fail( new InternalServerError({ message: "Database error while toggling reaction", - detail: String(err), + cause: err, }), ), ), diff --git a/apps/backend/src/routes/bot-commands.http.ts b/apps/backend/src/routes/bot-commands.http.ts index e042954bb..ee9de541d 100644 --- a/apps/backend/src/routes/bot-commands.http.ts +++ b/apps/backend/src/routes/bot-commands.http.ts @@ -158,7 +158,7 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" Effect.fail( new InternalServerError({ message: "Database error while syncing commands", - detail: String(error), + cause: error, }), ), ), @@ -239,7 +239,7 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" Effect.fail( new InternalServerError({ message: "Database error while executing command", - detail: String(error), + cause: error, }), ), ), @@ -321,7 +321,7 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" Effect.fail( new InternalServerError({ message: `Failed to refresh ${params.provider} token`, - detail: String(error.cause), + cause: error.cause, }), ), ), @@ -332,7 +332,7 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" Effect.fail( new InternalServerError({ message: `Failed to decrypt ${params.provider} token`, - detail: String(error), + cause: error, }), ), ), @@ -340,7 +340,7 @@ export const HttpBotCommandsLive = HttpApiBuilder.group(HazelApi, "bot-commands" Effect.fail( new InternalServerError({ message: `Encryption key version not found for ${params.provider} token`, - detail: String(error), + cause: error, }), ), ), diff --git a/apps/backend/src/routes/chat-sync.http.ts b/apps/backend/src/routes/chat-sync.http.ts index 31dd7e377..da7d0df67 100644 --- a/apps/backend/src/routes/chat-sync.http.ts +++ b/apps/backend/src/routes/chat-sync.http.ts @@ -16,7 +16,7 @@ import { IntegrationConnectionRepo, } from "@hazel/backend-core" import { ExternalChannelId } from "@hazel/schema" -import { CurrentUser, InternalServerError, UnauthorizedError } from "@hazel/domain" +import { CurrentUser, InternalServerError, UnauthorizedError, withRemapDbErrors } from "@hazel/domain" import { HttpApiBuilder } from "effect/http-api" import { Effect, Option } from "effect" import { HazelApi } from "../api" @@ -34,12 +34,6 @@ const ensureOrgAccess = Effect.fn("chatSync.ensureOrgAccess")(function* (organiz } }) -const toInternalServerError = (message: string, error: unknown) => - new InternalServerError({ - message, - detail: String(error), - }) - const normalizeChannelLinkSettings = ( settings: Record | null | undefined, ): Record => ({ @@ -120,29 +114,14 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han const txid = yield* generateTransactionId() return new ChatSyncConnectionResponse({ data: connection, transactionId: txid }) - }).pipe( - Effect.catchTag("SchemaError", (error) => - Effect.fail(toInternalServerError("Invalid sync connection data", error)), - ), - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - toInternalServerError("Database error while creating sync connection", error), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncConnection", "create")), ) .handle("listConnections", ({ params }) => Effect.gen(function* () { yield* ensureOrgAccess(params.orgId) const connections = yield* connectionRepo.findByOrganization(params.orgId) return new ChatSyncConnectionListResponse({ data: connections }) - }).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - toInternalServerError("Database error while listing sync connections", error), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncConnection", "select")), ) .handle("deleteConnection", ({ params }) => Effect.gen(function* () { @@ -166,13 +145,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han const txid = yield* generateTransactionId() return new ChatSyncDeleteResponse({ transactionId: txid }) - }).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - toInternalServerError("Database error while deleting sync connection", error), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncConnection", "delete")), ) .handle("createChannelLink", ({ params, payload }) => Effect.gen(function* () { @@ -233,16 +206,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han } const txid = yield* generateTransactionId() return new ChatSyncChannelLinkResponse({ data: brandedLink, transactionId: txid }) - }).pipe( - Effect.catchTag("SchemaError", (error) => - Effect.fail(toInternalServerError("Invalid channel link data", error)), - ), - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - toInternalServerError("Database error while creating channel link", error), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncChannelLink", "create")), ) .handle("listChannelLinks", ({ params }) => Effect.gen(function* () { @@ -259,13 +223,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han const links = yield* channelLinkRepo.findBySyncConnection(params.syncConnectionId) return new ChatSyncChannelLinkListResponse({ data: links }) - }).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - toInternalServerError("Database error while listing channel links", error), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncChannelLink", "select")), ) .handle("deleteChannelLink", ({ params }) => Effect.gen(function* () { @@ -292,13 +250,7 @@ export const HttpChatSyncLive = HttpApiBuilder.group(HazelApi, "chat-sync", (han yield* channelLinkRepo.softDelete(params.syncChannelLinkId) const txid = yield* generateTransactionId() return new ChatSyncDeleteResponse({ transactionId: txid }) - }).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - toInternalServerError("Database error while deleting channel link", error), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncChannelLink", "delete")), ) }), ) diff --git a/apps/backend/src/routes/incoming-webhooks.http.ts b/apps/backend/src/routes/incoming-webhooks.http.ts index 13e440bc8..f3b76ba2e 100644 --- a/apps/backend/src/routes/incoming-webhooks.http.ts +++ b/apps/backend/src/routes/incoming-webhooks.http.ts @@ -5,6 +5,7 @@ import { Database } from "@hazel/db" import type { MessageEmbed as DbMessageEmbed } from "@hazel/db" import { InternalServerError } from "@hazel/domain" import { + InvalidWebhookPayloadError, InvalidWebhookTokenError, WebhookDisabledError, WebhookMessageResponse, @@ -92,9 +93,8 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- // Validate payload has content or embeds if (!payload.content && (!payload.embeds || payload.embeds.length === 0)) { return yield* Effect.fail( - new InternalServerError({ + new InvalidWebhookPayloadError({ message: "Message must have content or embeds", - detail: "Provide either 'content' or 'embeds' in the payload", }), ) } @@ -102,9 +102,8 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- // Limit number of embeds (like Discord) if (payload.embeds && payload.embeds.length > 10) { return yield* Effect.fail( - new InternalServerError({ - message: "Too many embeds", - detail: "Maximum 10 embeds per message", + new InvalidWebhookPayloadError({ + message: "Too many embeds: maximum 10 embeds per message", }), ) } @@ -139,8 +138,10 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }), ) - // Update last used timestamp (fire and forget) - yield* webhookRepo.updateLastUsed(webhook.id).pipe(Effect.ignore) + // Best-effort: a missed lastUsedAt update must not fail the delivery + yield* webhookRepo + .updateLastUsed(webhook.id) + .pipe(Effect.ignore({ log: "Debug", message: "Failed to update webhook lastUsedAt" })) return new WebhookMessageResponse({ messageId: message.id, @@ -148,18 +149,18 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }) }).pipe( Effect.catchTags({ - DatabaseError: (error: unknown) => + DatabaseError: (error) => Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), - SchemaError: (error: unknown) => + // The message built from the payload failed the message schema (e.g. too long). + SchemaError: () => Effect.fail( - new InternalServerError({ - message: "Invalid request data", - detail: String(error), + new InvalidWebhookPayloadError({ + message: "The payload does not make a valid message", }), ), }), @@ -242,8 +243,10 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }), ) - // Update last used timestamp (fire and forget) - yield* webhookRepo.updateLastUsed(webhook.id).pipe(Effect.ignore) + // Best-effort: a missed lastUsedAt update must not fail the delivery + yield* webhookRepo + .updateLastUsed(webhook.id) + .pipe(Effect.ignore({ log: "Debug", message: "Failed to update webhook lastUsedAt" })) return new WebhookMessageResponse({ messageId: message.id, @@ -251,18 +254,18 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }) }).pipe( Effect.catchTags({ - DatabaseError: (error: unknown) => + DatabaseError: (error) => Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), - SchemaError: (error: unknown) => + // The message built from the payload failed the message schema (e.g. too long). + SchemaError: () => Effect.fail( - new InternalServerError({ - message: "Invalid request data", - detail: String(error), + new InvalidWebhookPayloadError({ + message: "The payload does not make a valid message", }), ), }), @@ -342,8 +345,10 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }), ) - // Update last used timestamp (fire and forget) - yield* webhookRepo.updateLastUsed(webhook.id).pipe(Effect.ignore) + // Best-effort: a missed lastUsedAt update must not fail the delivery + yield* webhookRepo + .updateLastUsed(webhook.id) + .pipe(Effect.ignore({ log: "Debug", message: "Failed to update webhook lastUsedAt" })) return new WebhookMessageResponse({ messageId: message.id, @@ -351,18 +356,18 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }) }).pipe( Effect.catchTags({ - DatabaseError: (error: unknown) => + DatabaseError: (error) => Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), - SchemaError: (error: unknown) => + // The message built from the payload failed the message schema (e.g. too long). + SchemaError: () => Effect.fail( - new InternalServerError({ - message: "Invalid request data", - detail: String(error), + new InvalidWebhookPayloadError({ + message: "The payload does not make a valid message", }), ), }), @@ -436,7 +441,9 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }), ) - yield* webhookRepo.updateLastUsed(webhook.id).pipe(Effect.ignore) + yield* webhookRepo + .updateLastUsed(webhook.id) + .pipe(Effect.ignore({ log: "Debug", message: "Failed to update webhook lastUsedAt" })) return new WebhookMessageResponse({ messageId: message.id, @@ -444,18 +451,18 @@ export const HttpIncomingWebhookLive = HttpApiBuilder.group(HazelApi, "incoming- }) }).pipe( Effect.catchTags({ - DatabaseError: (error: unknown) => + DatabaseError: (error) => Effect.fail( new InternalServerError({ message: "Database error while creating message", - detail: String(error), + cause: error, }), ), - SchemaError: (error: unknown) => + // The message built from the payload failed the message schema (e.g. too long). + SchemaError: () => Effect.fail( - new InternalServerError({ - message: "Invalid request data", - detail: String(error), + new InvalidWebhookPayloadError({ + message: "The payload does not make a valid message", }), ), }), diff --git a/apps/backend/src/routes/integration-commands.http.ts b/apps/backend/src/routes/integration-commands.http.ts index 22ae7d1e7..ae9ea532d 100644 --- a/apps/backend/src/routes/integration-commands.http.ts +++ b/apps/backend/src/routes/integration-commands.http.ts @@ -57,7 +57,7 @@ export const HttpIntegrationCommandLive = HttpApiBuilder.group(HazelApi, "integr Effect.fail( new InternalServerError({ message: "Database error while fetching commands", - detail: String(error), + cause: error, }), ), ), diff --git a/apps/backend/src/routes/integration-resources.http.ts b/apps/backend/src/routes/integration-resources.http.ts index 5aff812ab..da41732fe 100644 --- a/apps/backend/src/routes/integration-resources.http.ts +++ b/apps/backend/src/routes/integration-resources.http.ts @@ -141,7 +141,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.fail( new InternalServerError({ message: "Database error while fetching integration", - detail: String(error), + cause: error, }), ), // When token decryption fails, prompt user to reconnect instead of showing 500 error @@ -292,7 +292,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.fail( new InternalServerError({ message: "Failed to fetch GitHub repositories", - detail: String(error), + cause: error, }), ), TokenNotFoundError: () => @@ -315,7 +315,7 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.fail( new InternalServerError({ message: "Failed to fetch Discord guilds", - detail: String(error), + cause: error, }), ), TokenNotFoundError: () => @@ -338,14 +338,14 @@ export const HttpIntegrationResourceLive = HttpApiBuilder.group( Effect.fail( new InternalServerError({ message: "Failed to fetch Discord channels", - detail: String(error), + cause: error, }), ), ConfigError: (error) => Effect.fail( new InternalServerError({ message: "Discord bot token not configured", - detail: String(error), + cause: error, }), ), }), @@ -391,7 +391,7 @@ const handleGetGitHubRepositories = Effect.fn("integration-resources.getGitHubRe (error) => new InternalServerError({ message: "Failed to fetch GitHub repositories", - detail: error.message, + cause: error, }), ), ) diff --git a/apps/backend/src/routes/integrations.http.test.ts b/apps/backend/src/routes/integrations.http.test.ts index ece39ad3d..eca511990 100644 --- a/apps/backend/src/routes/integrations.http.test.ts +++ b/apps/backend/src/routes/integrations.http.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest" import { CraftApiError, CraftNotFoundError, CraftRateLimitError } from "@hazel/integrations/craft" -import { Effect, Result } from "effect" +import { Effect, Option, Result } from "effect" import { mapCraftConnectApiKeyError, parseOAuthStateParam, @@ -26,22 +26,24 @@ describe("integration connect API key helpers", () => { } it("parses raw JSON state", () => { - const parsed = parseOAuthStateParam(JSON.stringify(baseState)) + const parsed = Option.getOrThrow(parseOAuthStateParam(JSON.stringify(baseState))) expect(parsed.organizationId).toBe(baseState.organizationId) expect(parsed.userId).toBe(baseState.userId) expect(parsed.level).toBe(baseState.level) }) it("parses URL-encoded JSON state", () => { - const parsed = parseOAuthStateParam(encodeURIComponent(JSON.stringify(baseState))) + const parsed = Option.getOrThrow( + parseOAuthStateParam(encodeURIComponent(JSON.stringify(baseState))), + ) expect(parsed.organizationId).toBe(baseState.organizationId) expect(parsed.userId).toBe(baseState.userId) expect(parsed.level).toBe(baseState.level) }) it("parses double URL-encoded JSON state (legacy)", () => { - const parsed = parseOAuthStateParam( - encodeURIComponent(encodeURIComponent(JSON.stringify(baseState))), + const parsed = Option.getOrThrow( + parseOAuthStateParam(encodeURIComponent(encodeURIComponent(JSON.stringify(baseState)))), ) expect(parsed.organizationId).toBe(baseState.organizationId) expect(parsed.userId).toBe(baseState.userId) @@ -49,13 +51,15 @@ describe("integration connect API key helpers", () => { }) it("defaults level to organization when missing", () => { - const parsed = parseOAuthStateParam( - JSON.stringify({ - organizationId: baseState.organizationId, - userId: baseState.userId, - returnTo: baseState.returnTo, - environment: baseState.environment, - }), + const parsed = Option.getOrThrow( + parseOAuthStateParam( + JSON.stringify({ + organizationId: baseState.organizationId, + userId: baseState.userId, + returnTo: baseState.returnTo, + environment: baseState.environment, + }), + ), ) expect(parsed.level).toBe("organization") }) @@ -101,18 +105,18 @@ describe("integration connect API key helpers", () => { expect(mapped._tag).toBe("InvalidApiKeyError") }) - it("maps Craft rate limits to InternalServerError", () => { + it("maps Craft rate limits to IntegrationUnavailableError", () => { const mapped = mapCraftConnectApiKeyError( new CraftRateLimitError({ message: "too many requests", retryAfter: 60 }), ) - expect(mapped._tag).toBe("InternalServerError") + expect(mapped._tag).toBe("IntegrationUnavailableError") }) - it("maps Craft 5xx failures to InternalServerError", () => { + it("maps Craft 5xx failures to IntegrationUnavailableError", () => { const mapped = mapCraftConnectApiKeyError( new CraftApiError({ message: "server error", status: 503 }), ) - expect(mapped._tag).toBe("InternalServerError") + expect(mapped._tag).toBe("IntegrationUnavailableError") }) it("maps Craft 4xx failures to InvalidApiKeyError", () => { @@ -122,9 +126,9 @@ describe("integration connect API key helpers", () => { expect(mapped._tag).toBe("InvalidApiKeyError") }) - it("maps Craft failures without status to InternalServerError", () => { + it("maps Craft failures without status to IntegrationUnavailableError", () => { const mapped = mapCraftConnectApiKeyError(new CraftApiError({ message: "network error" })) - expect(mapped._tag).toBe("InternalServerError") + expect(mapped._tag).toBe("IntegrationUnavailableError") }) }) }) diff --git a/apps/backend/src/routes/integrations.http.ts b/apps/backend/src/routes/integrations.http.ts index 8ced255fa..1e8779bf8 100644 --- a/apps/backend/src/routes/integrations.http.ts +++ b/apps/backend/src/routes/integrations.http.ts @@ -1,12 +1,13 @@ import { HttpApiBuilder } from "effect/http-api" import { HttpServerRequest, HttpServerResponse, Cookies } from "effect/http" import { IntegrationConnectionRepo, OrganizationRepo } from "@hazel/backend-core" -import { CurrentUser, InternalServerError, UnauthorizedError } from "@hazel/domain" +import { CurrentUser, InternalServerError, UnauthorizedError, withRemapDbErrors } from "@hazel/domain" import type { OrganizationId, UserId } from "@hazel/schema" import { ConnectApiKeyResponse, ConnectionStatusResponse, IntegrationNotConnectedError, + IntegrationUnavailableError, InvalidApiKeyError, InvalidOAuthStateError, UnsupportedProviderError, @@ -18,7 +19,7 @@ import { CraftRateLimitError, } from "@hazel/integrations/craft" import type { IntegrationConnection } from "@hazel/domain/models" -import { Config, DateTime, Effect, Layer, Option, Schedule, Schema, SchemaGetter } from "effect" +import { Cause, Config, DateTime, Effect, Layer, Option, Schedule, Schema, SchemaGetter } from "effect" import * as Duration from "effect/Duration" import { HazelApi } from "../api" import { ChatSyncAttributionReconciler } from "../services/chat-sync/chat-sync-attribution-reconciler" @@ -91,36 +92,24 @@ export const validateCraftBaseUrl = (rawBaseUrl: string) => type CraftConnectApiKeyError = CraftApiError | CraftNotFoundError | CraftRateLimitError +/** Rate limits, 5xx and transport failures are Craft being unavailable; other 4xx mean a bad key. */ export const mapCraftConnectApiKeyError = ( error: CraftConnectApiKeyError, -): InvalidApiKeyError | InternalServerError => { - if (error._tag === "CraftNotFoundError") { - return invalidCraftCredentialsError() - } - - if (error._tag === "CraftRateLimitError") { - return new InternalServerError({ - message: "Craft API is temporarily unavailable", - detail: error.message, - }) - } - - const status = error.status - if (status === undefined || status === 429 || status >= 500) { - return new InternalServerError({ - message: "Craft API is temporarily unavailable", - detail: error.message, - }) - } - - if (status >= 400 && status < 500) { - return invalidCraftCredentialsError() +): InvalidApiKeyError | IntegrationUnavailableError => { + const unavailable = () => + new IntegrationUnavailableError({ message: "Craft API is temporarily unavailable" }) + switch (error._tag) { + case "CraftNotFoundError": + return invalidCraftCredentialsError() + case "CraftRateLimitError": + return unavailable() + case "CraftApiError": { + const status = error.status + return status !== undefined && status >= 400 && status < 500 && status !== 429 + ? invalidCraftCredentialsError() + : unavailable() + } } - - return new InternalServerError({ - message: "Craft API is temporarily unavailable", - detail: error.message, - }) } const craftConnectApiKeyErrorLogFields = (error: CraftConnectApiKeyError): Record => { @@ -220,25 +209,15 @@ const getOAuthStateCandidates = (rawState: string): ReadonlyArray => { return candidates } +const decodeOAuthState = Schema.decodeUnknownOption(Schema.fromJsonString(OAuthState)) +const decodeUriComponent = Option.liftThrowable(decodeURIComponent) + /** * Parse OAuth state from callback query param. * Supports raw JSON, single-encoded JSON, and legacy double-encoded JSON. */ -export const parseOAuthStateParam = (rawState: string): typeof OAuthState.Type => { - const candidates = getOAuthStateCandidates(rawState) - let lastError: unknown = null - - for (const candidate of candidates) { - try { - const parsed = JSON.parse(candidate) - return Schema.decodeUnknownSync(OAuthState)(parsed) - } catch (error) { - lastError = error - } - } - - throw lastError ?? new Error("Failed to parse OAuth state") -} +export const parseOAuthStateParam = (rawState: string): Option.Option => + Option.firstSomeOf(getOAuthStateCandidates(rawState).map((candidate) => decodeOAuthState(candidate))) const makeOAuthSessionCookie = ( name: string, @@ -262,7 +241,7 @@ const makeOAuthSessionCookie = ( catch: (error) => new InternalServerError({ message: "Failed to create OAuth session cookie", - detail: String(error), + cause: error, }), }) @@ -306,7 +285,7 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( (error) => new InternalServerError({ message: `Provider not available: ${error._tag}`, - detail: String(error), + cause: error, }), ), ) @@ -321,7 +300,7 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( (error) => new InternalServerError({ message: "Failed to fetch organization", - detail: String(error), + cause: error, }), ), ) @@ -403,9 +382,7 @@ const handleGetOAuthUrl = Effect.fn("integrations.getOAuthUrl")(function* ( }, ).pipe( Effect.catchTag("HttpBodyError", (e) => - Effect.fail( - new InternalServerError({ message: "Failed to serialize response", detail: String(e) }), - ), + Effect.fail(new InternalServerError({ message: "Failed to serialize response", cause: e })), ), ) }) @@ -485,10 +462,7 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( if (encodedState) { // Priority 1: State from URL parameter - const stateResult = yield* Effect.try({ - try: () => parseOAuthStateParam(encodedState), - catch: (e) => new InvalidOAuthStateError({ message: `Invalid state: ${e}` }), - }).pipe(Effect.option) + const stateResult = parseOAuthStateParam(encodedState) if (Option.isSome(stateResult)) { parsedState = stateResult.value @@ -503,11 +477,9 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( provider, }) - const sessionResult = yield* Effect.try({ - try: () => - Schema.decodeUnknownSync(OAuthSessionState)(JSON.parse(decodeURIComponent(sessionCookie))), - catch: (e) => new InvalidOAuthStateError({ message: `Invalid session cookie: ${e}` }), - }).pipe(Effect.option) + const sessionResult = Option.flatMap(decodeUriComponent(sessionCookie), (json) => + Schema.decodeUnknownOption(Schema.fromJsonString(OAuthSessionState))(json), + ) if (Option.isSome(sessionResult)) { const session = sessionResult.value @@ -577,9 +549,15 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( const connection = connections[0]! // Get the organization to find its slug - const orgOption = yield* orgRepo - .findById(connection.organizationId) - .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed(Option.none()))) + const orgOption = yield* orgRepo.findById(connection.organizationId).pipe( + // The redirect below still works without the slug; don't fail the callback over it. + Effect.catchTag("DatabaseError", (error) => + Effect.logWarning( + "GitHub update callback: organization lookup failed", + Cause.fail(error), + ).pipe(Effect.as(Option.none())), + ), + ) if (Option.isNone(orgOption)) { yield* Effect.logWarning("GitHub update callback: organization not found", { @@ -928,16 +906,11 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( organizationId: parsedState.organizationId, }), ), - // Note: catchAll is intentional here - this is a best-effort operation - // after OAuth success. We catch all errors to prevent disrupting the flow. - Effect.catch((error) => - Effect.logWarning("Failed to add integration bot to org (non-critical)", { - event: "integration_bot_add_failed", - provider, - organizationId: parsedState.organizationId, - error: String(error), - }), - ), + // Best-effort after a successful OAuth connection: a failure must not break the redirect. + Effect.catchTags({ + DatabaseError: (error) => logBotAddFailure(provider, parsedState.organizationId, error), + SchemaError: (error) => logBotAddFailure(provider, parsedState.organizationId, error), + }), ) } @@ -958,6 +931,13 @@ const handleOAuthCallback = Effect.fn("integrations.oauthCallback")(function* ( * Connect an integration using an API key (non-OAuth providers like Craft). * Validates the token by calling the provider's API, then stores the connection. */ +const logBotAddFailure = (provider: string, organizationId: string, error: unknown) => + Effect.logWarning("Failed to add integration bot to org (non-critical)", Cause.fail(error), { + event: "integration_bot_add_failed", + provider, + organizationId, + }) + const handleConnectApiKey = Effect.fn("integrations.connectApiKey")(function* ( path: { orgId: OrganizationId @@ -1051,14 +1031,10 @@ const handleConnectApiKey = Effect.fn("integrations.connectApiKey")(function* ( // Best-effort: add integration bot to org const integrationBotService = yield* IntegrationBotService yield* integrationBotService.addBotToOrg(provider, orgId).pipe( - Effect.catch((error) => - Effect.logWarning("Failed to add integration bot to org (non-critical)", { - event: "integration_bot_add_failed", - provider, - organizationId: orgId, - error: String(error), - }), - ), + Effect.catchTags({ + DatabaseError: (error) => logBotAddFailure(provider, orgId, error), + SchemaError: (error) => logBotAddFailure(provider, orgId, error), + }), ) return new ConnectApiKeyResponse({ @@ -1209,81 +1185,34 @@ export const HttpIntegrationLive = HttpApiBuilder.group(HazelApi, "integrations" .handle("getOAuthUrl", ({ params, query }) => handleGetOAuthUrl(params, query).pipe( Effect.catchTag("ConfigError", (err) => - Effect.fail( - new InternalServerError({ message: "Missing configuration", detail: String(err) }), - ), + Effect.fail(new InternalServerError({ message: "Missing configuration", cause: err })), ), ), ) .handle("oauthCallback", ({ params, query }) => handleOAuthCallback(params, query).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error during OAuth callback", - detail: String(error), - }), - ), - ), + withRemapDbErrors("IntegrationConnection", "create"), Effect.catchTag("ConfigError", (err) => - Effect.fail( - new InternalServerError({ message: "Missing configuration", detail: String(err) }), - ), + Effect.fail(new InternalServerError({ message: "Missing configuration", cause: err })), ), ), ) .handle("connectApiKey", ({ params, payload }) => handleConnectApiKey(params, payload).pipe( - Effect.catchTags({ - DatabaseError: (error: { readonly _tag: "DatabaseError"; readonly message?: string }) => - Effect.fail( - new InternalServerError({ - message: "Database error during API key connection", - detail: String(error), - }), - ), - SchemaError: (error: { readonly _tag: "SchemaError"; readonly message?: string }) => - Effect.fail( - new InternalServerError({ - message: "Failed to parse API response", - detail: String(error), - }), - ), - IntegrationEncryptionError: (error: { - readonly _tag: "IntegrationEncryptionError" - readonly message?: string - }) => - Effect.fail( - new InternalServerError({ - message: "Failed to encrypt token", - detail: String(error), - }), - ), - }), + withRemapDbErrors("IntegrationConnection", "create"), + Effect.catchTag("IntegrationEncryptionError", (error) => + Effect.fail( + new InternalServerError({ message: "Failed to encrypt token", cause: error }), + ), + ), ), ) .handle("getConnectionStatus", ({ params, query }) => handleGetConnectionStatus(params, query).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Failed to get connection status", - detail: String(error), - }), - ), - ), + withRemapDbErrors("IntegrationConnection", "select"), ), ) .handle("disconnect", ({ params, query }) => - handleDisconnect(params, query).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Failed to disconnect integration", - detail: String(error), - }), - ), - ), - ), + handleDisconnect(params, query).pipe(withRemapDbErrors("IntegrationConnection", "delete")), ), ).pipe(Layer.provide(CraftApiClient.layer), Layer.provide(IntegrationBotService.layer)) diff --git a/apps/backend/src/routes/uploads.http.ts b/apps/backend/src/routes/uploads.http.ts index 94a82b5b3..18ecc8985 100644 --- a/apps/backend/src/routes/uploads.http.ts +++ b/apps/backend/src/routes/uploads.http.ts @@ -86,7 +86,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle Effect.mapError( (error) => new UploadError({ - message: `Failed to generate presigned URL: ${error.message}`, + message: "Failed to generate presigned URL", + cause: error, }), ), ) @@ -161,7 +162,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle Effect.mapError( (error) => new UploadError({ - message: `Failed to generate presigned URL: ${error.message}`, + message: "Failed to generate presigned URL", + cause: error, }), ), ) @@ -233,7 +235,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle Effect.mapError( (error) => new UploadError({ - message: `Failed to generate presigned URL: ${error.message}`, + message: "Failed to generate presigned URL", + cause: error, }), ), ) @@ -295,7 +298,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle Effect.mapError( (error) => new UploadError({ - message: `Failed to generate presigned URL: ${error.message}`, + message: "Failed to generate presigned URL", + cause: error, }), ), ) @@ -358,7 +362,8 @@ export const HttpUploadsLive = HttpApiBuilder.group(HazelApi, "uploads", (handle Effect.mapError( (error) => new UploadError({ - message: `Failed to generate presigned URL: ${error.message}`, + message: "Failed to generate presigned URL", + cause: error, }), ), ) diff --git a/apps/backend/src/routes/webhooks.http.ts b/apps/backend/src/routes/webhooks.http.ts index 86c8eef8c..da06e6607 100644 --- a/apps/backend/src/routes/webhooks.http.ts +++ b/apps/backend/src/routes/webhooks.http.ts @@ -1,297 +1,248 @@ import { createHmac, timingSafeEqual } from "node:crypto" -import { verifyWebhook as verifyClerkWebhook } from "@clerk/backend/webhooks" import { HttpApiBuilder } from "effect/http-api" import { HttpServerRequest } from "effect/http" -import { InternalServerError, WorkflowInitializationError } from "@hazel/domain" -import { GitHubWebhookResponse, InvalidGitHubWebhookSignature } from "@hazel/domain/http" -import { Config, Effect, pipe, Redacted } from "effect" -import { HazelApi, InvalidWebhookSignature, WebhookResponse } from "../api" -import { ClerkSync } from "@hazel/backend-core/services" -import { ChannelAccessSyncService } from "../services/channel-access-sync" +import { + InternalServerError, + WorkflowInitializationError, + WorkflowServiceUnavailableError, +} from "@hazel/domain" +import { + GitHubWebhookResponse, + InvalidGitHubWebhookPayload, + InvalidGitHubWebhookSignature, +} from "@hazel/domain/http" +import { Config, Effect, Option, pipe, Redacted, Schema } from "effect" +import { HazelApi } from "../api" import { makeClusterClient } from "../lib/cluster-client" -export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handlers) => - handlers - .handle("clerk", (_args) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest +/** Installation lifecycle actions the installation workflow handles; others are acknowledged and skipped. */ +const InstallationAction = Schema.Literals(["created", "deleted", "suspend", "unsuspend"]) + +const InstallationEvent = Schema.Struct({ + action: InstallationAction, + installation: Schema.Struct({ + id: Schema.Number, + account: Schema.Struct({ + login: Schema.String, + type: Schema.Literals(["User", "Organization"]), + }), + }), + sender: Schema.optional(Schema.Struct({ login: Schema.String })), +}) + +/** The fields every repository event the webhook workflow handles carries. */ +const RepositoryEvent = Schema.Struct({ + installation: Schema.Struct({ id: Schema.Number }), + repository: Schema.Struct({ id: Schema.Number, full_name: Schema.String }), +}) + +const LoggedFields = Schema.Struct({ + action: Schema.optional(Schema.String), + repository: Schema.optional(Schema.Struct({ full_name: Schema.optional(Schema.String) })), +}) + +const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)) +const decodeInstallationAction = Schema.decodeUnknownOption(Schema.Struct({ action: InstallationAction })) +const decodeInstallationEvent = Schema.decodeUnknownEffect(InstallationEvent) +const decodeRepositoryEvent = Schema.decodeUnknownOption(RepositoryEvent) +const decodeLoggedFields = Schema.decodeUnknownOption(LoggedFields) - const rawBody = yield* pipe( - request.text, - Effect.mapError( - () => - new InvalidWebhookSignature({ - message: "Invalid request body", - }), - ), - ) - - const signingSecret = yield* Effect.gen(function* () { - return yield* Config.String("CLERK_WEBHOOK_SECRET") - }).pipe( - Effect.catchTag("ConfigError", (err) => - Effect.fail( - new InternalServerError({ - message: "CLERK_WEBHOOK_SECRET not configured", - detail: String(err), - }), - ), - ), +export const HttpWebhookLive = HttpApiBuilder.group(HazelApi, "webhooks", (handlers) => + handlers.handle("github", (_args) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + + const eventType = request.headers["x-github-event"] + const signature = request.headers["x-hub-signature-256"] + const deliveryId = request.headers["x-github-delivery"] + + if (!eventType || !deliveryId) { + return yield* Effect.fail( + new InvalidGitHubWebhookSignature({ + message: "Missing required GitHub webhook headers", + }), ) + } - // Reconstruct a WHATWG Request so @clerk/backend's verifyWebhook can read Svix headers. - const stdRequest = new Request("https://webhooks.local/clerk", { - method: "POST", - headers: request.headers as unknown as HeadersInit, - body: rawBody, - }) - - const event = yield* Effect.tryPromise({ - try: () => verifyClerkWebhook(stdRequest, { signingSecret }), - catch: (error) => - new InvalidWebhookSignature({ - message: `Clerk webhook verification failed: ${error}`, + const rawBody = yield* pipe( + request.text, + Effect.mapError( + () => + new InvalidGitHubWebhookSignature({ + message: "Invalid request body", }), - }) + ), + ) - const syncService = yield* ClerkSync - const channelAccessSync = yield* ChannelAccessSyncService - const result = yield* syncService.processWebhookEvent(event) + const skipSignatureVerification = yield* Config.Boolean("GITHUB_WEBHOOK_SKIP_SIGNATURE").pipe( + Config.withDefault(false), + ) - if (!result.success) { - yield* Effect.logError("Failed to process Clerk webhook event", { - type: event.type, - error: result.error, - }) - } - - // Re-sync channel access when membership changes come in via webhook (e.g. after - // a Clerk invitation is accepted). The RPC-backed membership path does this - // inline; Clerk-originated changes arrive here instead, so we replicate it. - if (result.success && result.membershipChange) { - yield* channelAccessSync - .syncUserInOrganization( - result.membershipChange.userId, - result.membershipChange.organizationId, - ) - .pipe( - Effect.catch((err) => - Effect.logError( - "Failed to sync channel access after Clerk membership event", - { - userId: result.membershipChange!.userId, - organizationId: result.membershipChange!.organizationId, - removed: result.membershipRemoved, - error: String(err), - }, - ), + const webhookSecret = yield* Effect.gen(function* () { + return yield* Config.Redacted("GITHUB_WEBHOOK_SECRET") + }).pipe( + Effect.catchTag("ConfigError", () => + skipSignatureVerification + ? Effect.succeed(Redacted.make("")) + : Effect.fail( + new InvalidGitHubWebhookSignature({ + message: + "GITHUB_WEBHOOK_SECRET not configured. Set GITHUB_WEBHOOK_SKIP_SIGNATURE=true to disable in development.", + }), ), - ) - } - - return new WebhookResponse({ - success: result.success, - message: result.success ? "Event processed successfully" : result.error, - }) - }), - ) - .handle("github", (_args) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - - const eventType = request.headers["x-github-event"] as string | undefined - const signature = request.headers["x-hub-signature-256"] as string | undefined - const deliveryId = request.headers["x-github-delivery"] as string | undefined + ), + ) - if (!eventType || !deliveryId) { + const secretValue = Redacted.value(webhookSecret) + if (secretValue) { + if (!signature) { + yield* Effect.logWarning("Missing GitHub webhook signature header") return yield* Effect.fail( new InvalidGitHubWebhookSignature({ - message: "Missing required GitHub webhook headers", + message: "Missing x-hub-signature-256 header", }), ) } - const rawBody = yield* pipe( - request.text, - Effect.mapError( - () => - new InvalidGitHubWebhookSignature({ - message: "Invalid request body", - }), - ), - ) - - const skipSignatureVerification = yield* Config.Boolean("GITHUB_WEBHOOK_SKIP_SIGNATURE").pipe( - Config.withDefault(false), - ) - - const webhookSecret = yield* Effect.gen(function* () { - return yield* Config.Redacted("GITHUB_WEBHOOK_SECRET") - }).pipe( - Effect.catchTag("ConfigError", () => - skipSignatureVerification - ? Effect.succeed(Redacted.make("")) - : Effect.fail( - new InvalidGitHubWebhookSignature({ - message: - "GITHUB_WEBHOOK_SECRET not configured. Set GITHUB_WEBHOOK_SKIP_SIGNATURE=true to disable in development.", - }), - ), - ), - ) - - const secretValue = Redacted.value(webhookSecret) - if (secretValue) { - if (!signature) { - yield* Effect.logWarning("Missing GitHub webhook signature header") - return yield* Effect.fail( - new InvalidGitHubWebhookSignature({ - message: "Missing x-hub-signature-256 header", - }), - ) - } - - const bodyHmac = createHmac("sha256", secretValue) - bodyHmac.update(rawBody) - const expectedSignature = `sha256=${bodyHmac.digest("hex")}` - - const signatureBuffer = Buffer.from(signature) - const expectedBuffer = Buffer.from(expectedSignature) - - if ( - signatureBuffer.length !== expectedBuffer.length || - !timingSafeEqual(signatureBuffer, expectedBuffer) - ) { - yield* Effect.logWarning("Invalid GitHub webhook signature", { - eventType, - deliveryId, - }) - return yield* Effect.fail( - new InvalidGitHubWebhookSignature({ - message: "Invalid webhook signature", - }), - ) - } - } - - const payload = yield* Effect.try({ - try: () => JSON.parse(rawBody) as Record, - catch: (err) => - new InternalServerError({ - message: "Invalid GitHub webhook JSON payload", - detail: String(err), - }), - }) + const bodyHmac = createHmac("sha256", secretValue) + bodyHmac.update(rawBody) + const expectedSignature = `sha256=${bodyHmac.digest("hex")}` - yield* Effect.logInfo("Received GitHub webhook", { - eventType, - deliveryId, - repository: (payload.repository as { full_name?: string } | undefined)?.full_name, - action: payload.action, - }) + const signatureBuffer = Buffer.from(signature) + const expectedBuffer = Buffer.from(expectedSignature) - const clusterUrl = yield* Config.String("CLUSTER_URL") - const client = yield* makeClusterClient(clusterUrl) - - const wrapWorkflowError = (label: string) => (err: unknown) => - Effect.fail( - new WorkflowInitializationError({ - message: label, - cause: String(err), + if ( + signatureBuffer.length !== expectedBuffer.length || + !timingSafeEqual(signatureBuffer, expectedBuffer) + ) { + yield* Effect.logWarning("Invalid GitHub webhook signature", { + eventType, + deliveryId, + }) + return yield* Effect.fail( + new InvalidGitHubWebhookSignature({ + message: "Invalid webhook signature", }), ) - - if (eventType === "installation") { - const installation = payload.installation as - | { id: number; account: { login: string; type: string } } - | undefined - const action = payload.action as string | undefined - const sender = payload.sender as { login: string } | undefined - - if ( - !installation?.id || - !installation?.account || - !action || - !["created", "deleted", "suspend", "unsuspend"].includes(action) - ) { - yield* Effect.logDebug( - "Skipping installation webhook - missing fields or unsupported action", - { - hasInstallation: !!installation?.id, - hasAccount: !!installation?.account, - action, - }, - ) - return new GitHubWebhookResponse({ processed: false, messagesCreated: 0 }) - } - - yield* client.workflows - .GitHubInstallationWorkflow({ - payload: { - deliveryId, - action: action as "created" | "deleted" | "suspend" | "unsuspend", - installationId: installation.id, - accountLogin: installation.account.login, - accountType: installation.account.type as "User" | "Organization", - senderLogin: sender?.login ?? "unknown", - }, - }) - .pipe( - Effect.tapError((err) => - Effect.logError("Failed to execute GitHub installation workflow", { - deliveryId, - action, - installationId: installation.id, - error: String(err), - }), - ), - Effect.catch(wrapWorkflowError("Failed to execute GitHub installation workflow")), - ) - - return new GitHubWebhookResponse({ processed: true, messagesCreated: 0 }) } + } - const repository = payload.repository as { id: number; full_name: string } | undefined - const installationId = (payload.installation as { id: number } | undefined)?.id - - if (!installationId || !repository?.id || !repository?.full_name) { - yield* Effect.logDebug("Skipping GitHub webhook - missing required fields", { - hasInstallation: !!installationId, - hasRepository: !!repository?.id, + const payload = yield* decodeJson(rawBody).pipe( + Effect.mapError( + () => new InvalidGitHubWebhookPayload({ message: "Webhook body is not valid JSON" }), + ), + ) + + const logged = decodeLoggedFields(payload) + yield* Effect.logInfo("Received GitHub webhook", { + eventType, + deliveryId, + repository: Option.getOrUndefined(logged)?.repository?.full_name, + action: Option.getOrUndefined(logged)?.action, + }) + + const clusterUrl = yield* Config.String("CLUSTER_URL") + const client = yield* makeClusterClient(clusterUrl) + + if (eventType === "installation") { + if (Option.isNone(decodeInstallationAction(payload))) { + yield* Effect.logDebug("Skipping installation webhook - unsupported action", { + deliveryId, }) return new GitHubWebhookResponse({ processed: false, messagesCreated: 0 }) } + const event = yield* decodeInstallationEvent(payload).pipe( + Effect.mapError( + () => + new InvalidGitHubWebhookPayload({ + message: "Installation event is missing its installation or account", + }), + ), + ) + yield* client.workflows - .GitHubWebhookWorkflow({ + .GitHubInstallationWorkflow({ payload: { deliveryId, - eventType, - installationId, - repositoryId: repository.id, - repositoryFullName: repository.full_name, - eventPayload: payload, + action: event.action, + installationId: event.installation.id, + accountLogin: event.installation.account.login, + accountType: event.installation.account.type, + senderLogin: event.sender?.login ?? "unknown", }, }) .pipe( - Effect.tapError((err) => - Effect.logError("Failed to execute GitHub webhook workflow", { - deliveryId, - eventType, - repository: repository.full_name, - error: String(err), - }), + Effect.catchTags({ + HttpClientError: (cause) => + Effect.fail( + new WorkflowServiceUnavailableError({ + message: "Failed to execute GitHub installation workflow", + cause, + }), + ), + }), + // What is left is the workflow's own failure union (one tag per activity). + Effect.mapError((cause) => + WorkflowServiceUnavailableError.is(cause) + ? cause + : new WorkflowInitializationError({ + message: "Failed to execute GitHub installation workflow", + cause, + }), ), - Effect.catch(wrapWorkflowError("Failed to execute GitHub webhook workflow")), ) return new GitHubWebhookResponse({ processed: true, messagesCreated: 0 }) - }).pipe( - Effect.catchTag("ConfigError", (err) => - Effect.fail( - new InternalServerError({ message: "Missing configuration", detail: String(err) }), + } + + const repositoryEvent = decodeRepositoryEvent(payload) + if (Option.isNone(repositoryEvent)) { + yield* Effect.logDebug("Skipping GitHub webhook - no installation or repository", { + deliveryId, + eventType, + }) + return new GitHubWebhookResponse({ processed: false, messagesCreated: 0 }) + } + const { installation, repository } = repositoryEvent.value + + yield* client.workflows + .GitHubWebhookWorkflow({ + payload: { + deliveryId, + eventType, + installationId: installation.id, + repositoryId: repository.id, + repositoryFullName: repository.full_name, + eventPayload: payload, + }, + }) + .pipe( + Effect.catchTags({ + HttpClientError: (cause) => + Effect.fail( + new WorkflowServiceUnavailableError({ + message: "Failed to execute GitHub webhook workflow", + cause, + }), + ), + }), + // What is left is the workflow's own failure union (one tag per activity). + Effect.mapError((cause) => + WorkflowServiceUnavailableError.is(cause) + ? cause + : new WorkflowInitializationError({ + message: "Failed to execute GitHub webhook workflow", + cause, + }), ), - ), + ) + + return new GitHubWebhookResponse({ processed: true, messagesCreated: 0 }) + }).pipe( + Effect.catchTag("ConfigError", (cause) => + Effect.fail(new InternalServerError({ message: "Missing configuration", cause })), ), ), + ), ) diff --git a/apps/backend/src/rpc/handlers/bots.ts b/apps/backend/src/rpc/handlers/bots.ts index 5770ccab4..7c0328a35 100644 --- a/apps/backend/src/rpc/handlers/bots.ts +++ b/apps/backend/src/rpc/handlers/bots.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto" import { BotCommandRepo, BotInstallationRepo, BotRepo, UserRepo } from "@hazel/backend-core" import { Database, PG, schema } from "@hazel/db" -import { CurrentUser, withRemapDbErrors } from "@hazel/domain" +import { ActiveOrganizationRequiredError, CurrentUser, withRemapDbErrors } from "@hazel/domain" import type { BotId, BotInstallationId, OrganizationMemberId, UserId } from "@hazel/schema" import { BotAlreadyInstalledError, @@ -46,7 +46,9 @@ export const BotRpcLive = BotRpcs.toLayer( yield* checkBotOperationRateLimit(currentUser.id) if (!currentUser.organizationId) { - return yield* Effect.die(new Error("User must belong to an organization")) + return yield* Effect.fail( + new ActiveOrganizationRequiredError({ message: "Select an organization first" }), + ) } const organizationId = currentUser.organizationId @@ -381,7 +383,9 @@ export const BotRpcLive = BotRpcs.toLayer( yield* checkBotOperationRateLimit(currentUser.id) if (!currentUser.organizationId) { - return yield* Effect.die(new Error("User must belong to an organization")) + return yield* Effect.fail( + new ActiveOrganizationRequiredError({ message: "Select an organization first" }), + ) } const organizationId = currentUser.organizationId @@ -459,7 +463,9 @@ export const BotRpcLive = BotRpcs.toLayer( yield* checkBotOperationRateLimit(currentUser.id) if (!currentUser.organizationId) { - return yield* Effect.die(new Error("User must belong to an organization")) + return yield* Effect.fail( + new ActiveOrganizationRequiredError({ message: "Select an organization first" }), + ) } const organizationId = currentUser.organizationId @@ -526,7 +532,9 @@ export const BotRpcLive = BotRpcs.toLayer( yield* checkBotOperationRateLimit(currentUser.id) if (!currentUser.organizationId) { - return yield* Effect.die(new Error("User must belong to an organization")) + return yield* Effect.fail( + new ActiveOrganizationRequiredError({ message: "Select an organization first" }), + ) } const organizationId = currentUser.organizationId diff --git a/apps/backend/src/rpc/handlers/channels.ts b/apps/backend/src/rpc/handlers/channels.ts index 05fa76a6f..a96b2a293 100644 --- a/apps/backend/src/rpc/handlers/channels.ts +++ b/apps/backend/src/rpc/handlers/channels.ts @@ -10,10 +10,11 @@ import { CurrentUser, DmChannelAlreadyExistsError, InternalServerError, + InvalidChannelRequestError, withRemapDbErrors, WorkflowServiceUnavailableError, } from "@hazel/domain" -import { type MessageId, OrganizationId } from "@hazel/schema" +import { OrganizationId } from "@hazel/schema" import { ChannelNotFoundError, ChannelResponse, ChannelRpcs, MessageNotFoundError } from "@hazel/domain/rpc" import { Config, Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" @@ -189,9 +190,8 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( // Validate participant count for single DMs if (payload.type === "single" && payload.participantIds.length !== 1) { return yield* Effect.fail( - new InternalServerError({ + new InvalidChannelRequestError({ message: "DM channels must have exactly one other participant", - cause: "Invalid participant count", }), ) } @@ -361,9 +361,8 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( // Optional client org must match the derived parent channel org. if (requestedOrganizationId && requestedOrganizationId !== organizationId) { return yield* Effect.fail( - new InternalServerError({ - message: "Thread creation organization mismatch", - detail: `messageId=${messageId} parentOrganizationId=${organizationId} requestedOrganizationId=${requestedOrganizationId}`, + new InvalidChannelRequestError({ + message: "The thread's organization does not match the message's", }), ) } @@ -431,16 +430,9 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( Effect.gen(function* () { yield* channelPolicy.canUpdate(channelId) - const channel = yield* channelRepo.findById(channelId).pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new InternalServerError({ - message: "Failed to query channel", - cause: String(err), - }), - ), - ), - ) + const channel = yield* channelRepo + .findById(channelId) + .pipe(withRemapDbErrors("Channel", "select", channelId)) if (Option.isNone(channel)) { return yield* Effect.fail(new ChannelNotFoundError({ channelId })) @@ -448,38 +440,34 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( if (channel.value.type !== "thread") { return yield* Effect.fail( - new InternalServerError({ - message: "Channel is not a thread", - cause: `Channel type: ${channel.value.type}`, - }), + new InvalidChannelRequestError({ message: "Only threads can be named" }), ) } - const originalMessageResult = yield* db - .run( + const originalMessage = yield* db + .first( PG.from(schema.messagesTable) .select("id") .where(($) => [$.threadChannelId.eq(channelId)]) .limit(1), ) - .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed([]))) + .pipe(withRemapDbErrors("Message", "select")) - if (originalMessageResult.length === 0) { + if (Option.isNone(originalMessage)) { return yield* Effect.fail( - new MessageNotFoundError({ - messageId: channelId as unknown as MessageId, + new InvalidChannelRequestError({ + message: "This thread has no original message", }), ) } - const originalMessageId = originalMessageResult[0]!.id + const originalMessageId = originalMessage.value.id const clusterUrl = yield* Config.String("CLUSTER_URL").pipe( Effect.mapError( () => new WorkflowServiceUnavailableError({ message: "CLUSTER_URL not configured", - cause: "Missing CLUSTER_URL environment variable", }), ), ) @@ -493,20 +481,13 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( }, }) .pipe( - Effect.tapError((err) => - Effect.logError("Failed to execute thread naming workflow", { - threadChannelId: channelId, - error: String(err), - errorTag: "_tag" in err ? err._tag : "unknown", - }), - ), // Workflow errors (ThreadChannelNotFoundError, AIProviderUnavailableError, etc.) // pass through directly since they're in the RPC union - only handle HTTP client errors Effect.catchTag("HttpClientError", (err) => Effect.fail( new WorkflowServiceUnavailableError({ message: "Cannot connect to workflow service", - cause: String(err), + cause: err, }), ), ), @@ -514,7 +495,7 @@ export const ChannelRpcLive = ChannelRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Failed to parse workflow response", - cause: String(err), + cause: err, }), ), ), diff --git a/apps/backend/src/rpc/handlers/chat-sync.ts b/apps/backend/src/rpc/handlers/chat-sync.ts index 7cc0359b2..6db167137 100644 --- a/apps/backend/src/rpc/handlers/chat-sync.ts +++ b/apps/backend/src/rpc/handlers/chat-sync.ts @@ -5,7 +5,7 @@ import { } from "@hazel/backend-core" import { Database } from "@hazel/db" import { ExternalChannelId } from "@hazel/schema" -import { CurrentUser, InternalServerError } from "@hazel/domain" +import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" import { ChatSyncChannelLinkExistsError, ChatSyncChannelLinkListResponse, @@ -114,40 +114,14 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( }) }), ) - .pipe( - Effect.catchTag("SchemaError", (error) => - Effect.fail( - new InternalServerError({ - message: "Invalid sync connection data", - detail: String(error), - }), - ), - ), - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while creating sync connection", - detail: String(error), - }), - ), - ), - ), + .pipe(withRemapDbErrors("ChatSyncConnection", "create")), "chatSync.connection.list": ({ organizationId }) => Effect.gen(function* () { yield* integrationConnectionPolicy.canSelect(organizationId) const data = yield* connectionRepo.findByOrganization(organizationId) return new ChatSyncConnectionListResponse({ data }) - }).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while listing sync connections", - detail: String(error), - }), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncConnection", "select")), "chatSync.connection.delete": ({ syncConnectionId }) => db @@ -174,16 +148,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( return { transactionId: txid } }), ) - .pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while deleting sync connection", - detail: String(error), - }), - ), - ), - ), + .pipe(withRemapDbErrors("ChatSyncConnection", "delete")), "chatSync.channelLink.create": (payload) => db @@ -251,24 +216,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( }) }), ) - .pipe( - Effect.catchTag("SchemaError", (error) => - Effect.fail( - new InternalServerError({ - message: "Invalid channel link data", - detail: String(error), - }), - ), - ), - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while creating channel link", - detail: String(error), - }), - ), - ), - ), + .pipe(withRemapDbErrors("ChatSyncChannelLink", "create")), "chatSync.channelLink.list": ({ syncConnectionId }) => Effect.gen(function* () { @@ -285,16 +233,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( const data = yield* channelLinkRepo.findBySyncConnection(syncConnectionId) return new ChatSyncChannelLinkListResponse({ data }) - }).pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while listing channel links", - detail: String(error), - }), - ), - ), - ), + }).pipe(withRemapDbErrors("ChatSyncChannelLink", "select")), "chatSync.channelLink.delete": ({ syncChannelLinkId }) => db @@ -328,16 +267,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( return { transactionId: txid } }), ) - .pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while deleting channel link", - detail: String(error), - }), - ), - ), - ), + .pipe(withRemapDbErrors("ChatSyncChannelLink", "delete")), "chatSync.channelLink.update": ({ syncChannelLinkId, direction, isActive }) => db .transaction( @@ -388,16 +318,7 @@ export const ChatSyncRpcLive = ChatSyncRpcs.toLayer( }) }), ) - .pipe( - Effect.catchTag("DatabaseError", (error) => - Effect.fail( - new InternalServerError({ - message: "Database error while updating channel link", - detail: String(error), - }), - ), - ), - ), + .pipe(withRemapDbErrors("ChatSyncChannelLink", "update")), } }), ) diff --git a/apps/backend/src/rpc/handlers/connect-shares.ts b/apps/backend/src/rpc/handlers/connect-shares.ts index b4359aec6..2d444ba3e 100644 --- a/apps/backend/src/rpc/handlers/connect-shares.ts +++ b/apps/backend/src/rpc/handlers/connect-shares.ts @@ -17,7 +17,9 @@ import { withRemapDbErrors, } from "@hazel/domain" import { + ChannelNotFoundError, ConnectChannelAlreadySharedError, + ConnectConversationNotFoundError, ConnectConversationResponse, ConnectInviteInvalidStateError, ConnectInviteListResponse, @@ -36,8 +38,8 @@ import { ChannelAccessSyncService } from "../../services/channel-access-sync" import { ConnectConversationService } from "../../services/connect-conversation-service" import { OrgResolver } from "../../services/org-resolver" -const isUniqueViolation = (error: any, constraintName: string) => - error?.type === "unique_violation" && error?.constraint === constraintName +const isUniqueViolation = (error: Database.DatabaseError, constraintName: string) => + error.type === "unique_violation" && error.constraint === constraintName export const resolveGuestOrganizationForInviteCreate = ({ providedOrgId, @@ -132,32 +134,32 @@ export const assertGuestMemberAddsAllowed = ( ) : Effect.void -export const remapGuestMountInsertConflict = ({ +export const remapGuestMountInsertConflict = ({ error, guestOrganizationId, findExistingMount, }: { - error: any + error: Database.DatabaseError guestOrganizationId: OrganizationId - findExistingMount: () => Effect.Effect, any, any> -}) => { + findExistingMount: () => Effect.Effect, E, R> +}): Effect.Effect => { if (!isUniqueViolation(error, "connect_conv_channels_conversation_org_unique")) { return Effect.fail(error) } return findExistingMount().pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(error), - onSome: (existingMount) => - Effect.fail( + Effect.flatMap((existingMount) => + Effect.fail( + Option.match(existingMount, { + onNone: (): Database.DatabaseError | ConnectChannelAlreadySharedError => error, + onSome: (mount) => new ConnectChannelAlreadySharedError({ - channelId: existingMount.channelId, + channelId: mount.channelId, organizationId: guestOrganizationId, message: "This workspace is already connected to the conversation", }), - ), - }), + }), + ), ), ) } @@ -247,12 +249,7 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( const currentUser = yield* CurrentUser.Context const channelOption = yield* channelRepo.findById(channelId) if (Option.isNone(channelOption)) { - return yield* Effect.fail( - new InternalServerError({ - message: "Channel not found while creating Hazel Connect invite", - detail: `channelId=${channelId}`, - }), - ) + return yield* Effect.fail(new ChannelNotFoundError({ channelId: channelId })) } const channel = channelOption.value yield* requireAdminOrOwner(channel.organizationId) @@ -346,10 +343,7 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( const hostChannelOption = yield* channelRepo.findById(invite.hostChannelId) if (Option.isNone(hostChannelOption)) { return yield* Effect.fail( - new InternalServerError({ - message: "Host channel missing while accepting Hazel Connect invite", - detail: `hostChannelId=${invite.hostChannelId}`, - }), + new ChannelNotFoundError({ channelId: invite.hostChannelId }), ) } const hostChannel = hostChannelOption.value @@ -452,17 +446,18 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( // Sync all channels in the conversation so cross-org users get access yield* channelAccessSync.syncConversation(invite.conversationId) - const conversation = yield* connectConversationRepo - .findById(invite.conversationId) - .pipe( - Effect.map((result) => - Option.getOrElse(result, () => { - throw new Error( - "Connect conversation disappeared during acceptance", - ) - }), - ), + const conversationOption = yield* connectConversationRepo.findById( + invite.conversationId, + ) + if (Option.isNone(conversationOption)) { + return yield* Effect.fail( + new ConnectConversationNotFoundError({ + conversationId: invite.conversationId, + message: "Hazel Connect conversation disappeared during acceptance", + }), ) + } + const conversation = conversationOption.value const txid = yield* generateTransactionId() return new ConnectConversationResponse({ data: conversation, @@ -575,9 +570,9 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( const conversationOption = yield* connectConversationRepo.findById(conversationId) if (Option.isNone(conversationOption)) { return yield* Effect.fail( - new InternalServerError({ + new ConnectConversationNotFoundError({ + conversationId, message: "Hazel Connect conversation not found", - detail: `conversationId=${conversationId}`, }), ) } @@ -614,12 +609,7 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( const currentUser = yield* CurrentUser.Context const channelOption = yield* channelRepo.findById(channelId) if (Option.isNone(channelOption)) { - return yield* Effect.fail( - new InternalServerError({ - message: "Channel not found while adding connect participant", - detail: `channelId=${channelId}`, - }), - ) + return yield* Effect.fail(new ChannelNotFoundError({ channelId: channelId })) } const channel = channelOption.value yield* orgResolver.fromChannel( @@ -633,9 +623,8 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( yield* connectConversationService.getConversationIdForChannel(channelId) if (!conversationId) { return yield* Effect.fail( - new InternalServerError({ - message: "No conversation available for channel", - detail: `channelId=${channelId}`, + new ConnectConversationNotFoundError({ + message: "This channel is not shared through Hazel Connect", }), ) } @@ -683,15 +672,18 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( // Sync all channels so the new participant gets access across the conversation yield* channelAccessSync.syncConversation(conversationId) - const participant = yield* connectParticipantRepo - .findByChannelAndUser(channelId, userId) - .pipe( - Effect.map((result) => - Option.getOrElse(result, () => { - throw new Error("Connect participant missing after upsert") - }), - ), + const participantOption = yield* connectParticipantRepo.findByChannelAndUser( + channelId, + userId, + ) + if (Option.isNone(participantOption)) { + return yield* Effect.fail( + new InternalServerError({ + message: "Connect participant missing after upsert", + }), ) + } + const participant = participantOption.value const txid = yield* generateTransactionId() return new ConnectParticipantResponse({ data: participant, transactionId: txid }) }), @@ -704,12 +696,7 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( Effect.gen(function* () { const channelOption = yield* channelRepo.findById(channelId) if (Option.isNone(channelOption)) { - return yield* Effect.fail( - new InternalServerError({ - message: "Channel not found while removing connect participant", - detail: `channelId=${channelId}`, - }), - ) + return yield* Effect.fail(new ChannelNotFoundError({ channelId: channelId })) } const channel = channelOption.value yield* orgResolver.fromChannel( @@ -758,9 +745,9 @@ export const ConnectShareRpcLive = ConnectShareRpcs.toLayer( const conversationOption = yield* connectConversationRepo.findById(conversationId) if (Option.isNone(conversationOption)) { return yield* Effect.fail( - new InternalServerError({ + new ConnectConversationNotFoundError({ + conversationId, message: "Hazel Connect conversation not found", - detail: `conversationId=${conversationId}`, }), ) } diff --git a/apps/backend/src/rpc/handlers/notifications.ts b/apps/backend/src/rpc/handlers/notifications.ts index 17ef9974c..e31cdc7b6 100644 --- a/apps/backend/src/rpc/handlers/notifications.ts +++ b/apps/backend/src/rpc/handlers/notifications.ts @@ -87,7 +87,9 @@ export const NotificationRpcLive = NotificationRpcs.toLayer( Effect.gen(function* () { // Skip if no message IDs provided if (messageIds.length === 0) { - const txid = yield* generateTransactionId() + const txid = yield* generateTransactionId().pipe( + withRemapDbErrors("Notification", "select"), + ) return { deletedCount: 0, transactionId: txid } } diff --git a/apps/backend/src/rpc/handlers/organization-members.ts b/apps/backend/src/rpc/handlers/organization-members.ts index 9719c1d2e..12e3abfcb 100644 --- a/apps/backend/src/rpc/handlers/organization-members.ts +++ b/apps/backend/src/rpc/handlers/organization-members.ts @@ -1,6 +1,6 @@ import { OrganizationMemberRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" -import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" +import { CurrentUser, withRemapDbErrors } from "@hazel/domain" import { OrganizationMemberNotFoundError, OrganizationMemberResponse, @@ -134,17 +134,7 @@ export const OrganizationMemberRpcLive = OrganizationMemberRpcs.toLayer( return { transactionId: txid } }), ) - .pipe( - Effect.catchTags({ - DatabaseError: (err) => - Effect.fail( - new InternalServerError({ - message: "Error Deleting Organization Member", - cause: err, - }), - ), - }), - ), + .pipe(withRemapDbErrors("OrganizationMember", "delete")), } }), ) diff --git a/apps/backend/src/rpc/handlers/organizations.ts b/apps/backend/src/rpc/handlers/organizations.ts index d10656709..7b97ca6cf 100644 --- a/apps/backend/src/rpc/handlers/organizations.ts +++ b/apps/backend/src/rpc/handlers/organizations.ts @@ -1,10 +1,5 @@ import type { OrganizationId } from "@hazel/schema" -import { - ChannelMemberRepo, - ChannelRepo, - OrganizationMemberRepo, - OrganizationRepo, -} from "@hazel/backend-core" +import { ChannelMemberRepo, ChannelRepo, OrganizationMemberRepo, OrganizationRepo } from "@hazel/backend-core" import { Database } from "@hazel/db" import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" import { @@ -15,80 +10,38 @@ import { OrganizationSlugAlreadyExistsError, PublicInviteDisabledError, } from "@hazel/domain/rpc" -import { Effect, Option, Predicate } from "effect" +import { Effect, Option } from "effect" import { generateTransactionId } from "../../lib/create-transactionId" import { OrganizationPolicy } from "../../policies/organization-policy" import { ChannelAccessSyncService } from "../../services/channel-access-sync" const UNKNOWN_ORGANIZATION_ID = "00000000-0000-4000-8000-000000000000" as OrganizationId +/** A unique violation on the slug: someone else has it. */ +const isSlugTaken = (error: unknown): error is Database.DatabaseError => + error instanceof Database.DatabaseError && + error.type === "unique_violation" && + error.constraint === "organizations_slug_unique" + +const slugTaken = (error: Database.DatabaseError) => { + const slug = error.detail?.match(/Key \(slug\)=\(([^)]+)\)/)?.[1] ?? "unknown" + return new OrganizationSlugAlreadyExistsError({ + message: `Organization slug '${slug}' is already taken`, + slug, + }) +} + /** - * Custom error handler for organization database operations that provides - * specific error handling for duplicate slug violations + * `withRemapDbErrors` for organization writes, except that a duplicate slug becomes + * `OrganizationSlugAlreadyExistsError`. */ -const handleOrganizationDbErrors = ( - entityType: string, - action: "update" | "create", -) => { - return ( - effect: Effect.Effect, - ): Effect.Effect< - R, - | Exclude - | InternalServerError - | OrganizationSlugAlreadyExistsError, - A - > => { - return effect.pipe( - Effect.catchIf( - (e): e is Extract => Predicate.isTagged(e, "DatabaseError"), - (err): Effect.Effect => { - const dbErr = err as unknown as Database.DatabaseError - // Check if it's a unique violation on the slug column - if ( - dbErr.type === "unique_violation" && - dbErr.constraint === "organizations_slug_unique" - ) { - // Extract slug from error detail if possible - const slugMatch = dbErr.detail?.match(/Key \(slug\)=\(([^)]+)\)/) - const slug = slugMatch?.[1] || "unknown" - return Effect.fail( - new OrganizationSlugAlreadyExistsError({ - message: `Organization slug '${slug}' is already taken`, - slug, - }), - ) - } - // For other database errors, return a generic internal server error - return Effect.fail( - new InternalServerError({ - message: `Error ${action}ing ${entityType}`, - detail: `There was a database error when ${action}ing the ${entityType}`, - cause: String(err), - }), - ) - }, - ), - Effect.catchIf( - (e): e is Extract => Predicate.isTagged(e, "SchemaError"), - (err) => - Effect.fail( - new InternalServerError({ - message: `Error ${action}ing ${entityType}`, - detail: `There was an error in parsing when ${action}ing the ${entityType}`, - cause: String(err), - }), - ), - ), - ) as Effect.Effect< - R, - | Exclude - | InternalServerError - | OrganizationSlugAlreadyExistsError, - A - > - } -} +const handleOrganizationDbErrors = + (entityType: string, action: "update" | "create") => + (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => (isSlugTaken(error) ? slugTaken(error) : error)), + withRemapDbErrors(entityType, action), + ) /** * Organization RPC Handlers @@ -157,9 +110,15 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( }) // Setup default channels for the organization - yield* organizationRepo.setupDefaultChannels(createdOrganization.id, currentUser.id) + yield* organizationRepo.setupDefaultChannels( + createdOrganization.id, + currentUser.id, + ) - yield* channelAccessSync.syncUserInOrganization(currentUser.id, createdOrganization.id) + yield* channelAccessSync.syncUserInOrganization( + currentUser.id, + createdOrganization.id, + ) const txid = yield* generateTransactionId() @@ -292,7 +251,7 @@ export const OrganizationRpcLive = OrganizationRpcs.toLayer( Effect.fail( new InternalServerError({ message: "Error fetching organization", - detail: String(err), + cause: err, }), ), }), diff --git a/apps/backend/src/rpc/middleware/auth-class.ts b/apps/backend/src/rpc/middleware/auth-class.ts deleted file mode 100644 index 947b236f1..000000000 --- a/apps/backend/src/rpc/middleware/auth-class.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Auth Middleware Class Definition (Client-Safe) - * - * This file contains ONLY the middleware class definition that is safe to import - * in browser code. The server-side implementation is in auth.ts. - * - * This separation prevents accidentally bundling server-side code (UserRepo, etc.) - * when frontend imports RPC group definitions that reference this middleware. - */ - -import { RpcMiddleware } from "effect/rpc" -import { - CurrentUser, - InvalidBearerTokenError, - InvalidJwtPayloadError, - SessionAuthenticationError, - SessionExpiredError, - SessionLoadError, - SessionNotProvidedError, - SessionRefreshError, - UnauthorizedError, -} from "@hazel/domain" -import { Schema as S } from "effect" - -/** - * Authentication middleware that provides CurrentUser context to RPC handlers. - * - * Verifies a Clerk bearer JWT from the `Authorization` header, loads the - * matching Hazel user, and exposes it to the handler via Effect context. - */ -const AuthFailure = S.Union([ - UnauthorizedError, - SessionLoadError, - SessionAuthenticationError, - InvalidJwtPayloadError, - SessionNotProvidedError, - SessionRefreshError, - SessionExpiredError, - InvalidBearerTokenError, -]) - -export class AuthMiddleware extends RpcMiddleware.Service< - AuthMiddleware, - { - provides: CurrentUser.Context - } ->()("AuthMiddleware", { - error: AuthFailure, - requiredForClient: true, -}) {} diff --git a/apps/backend/src/rpc/middleware/auth.test.ts b/apps/backend/src/rpc/middleware/auth.test.ts index 821a5e24b..c8c14cc86 100644 --- a/apps/backend/src/rpc/middleware/auth.test.ts +++ b/apps/backend/src/rpc/middleware/auth.test.ts @@ -3,17 +3,16 @@ import { Headers } from "effect/http" import type { SuccessValue } from "effect/rpc/RpcMiddleware" import { BotRepo, UserRepo } from "@hazel/backend-core" import { CurrentUser, InvalidBearerTokenError, type CurrentUser as CurrentUserNamespace } from "@hazel/domain" +import { Database } from "@hazel/db" import type { UserId } from "@hazel/schema" import { Effect, Layer, Option, Ref, Result, Context } from "effect" import { AuthMiddleware, AuthMiddlewareLive } from "./auth.ts" import { HazelSession } from "../../services/hazel-session.ts" -import { SessionManager } from "../../services/session-manager.ts" import { serviceShape } from "../../test/effect-helpers" const USER_ID = "00000000-0000-4000-8000-000000000001" as UserId const BOT_USER_ID = "00000000-0000-4000-8000-000000000002" as UserId -type SessionManagerShape = Context.Service.Shape type BotRepoShape = Context.Service.Shape type UserRepoShape = Context.Service.Shape type EffectSuccess = T extends Effect.Effect ? A : never @@ -66,14 +65,6 @@ const invokeMiddleware = (headers: Headers.Headers) => return yield* Ref.get(currentUserRef) }) -const makeSessionManagerLayer = (currentUser: CurrentUserNamespace.Schema) => - Layer.succeed( - SessionManager, - serviceShape({ - authenticateWithBearer: () => Effect.succeed(currentUser), - }), - ) - type HazelSessionShape = Context.Service.Shape const makeHazelSessionLayer = (overrides: Partial = {}) => @@ -143,7 +134,6 @@ const USER_RECORD: UserRecord = { const runAuth = ( headers: Headers.Headers, overrides: { - sessionManager?: ReturnType hazelSession?: ReturnType botRepo?: ReturnType userRepo?: ReturnType @@ -153,7 +143,6 @@ const runAuth = ( Effect.scoped( invokeMiddleware(headers).pipe( Effect.provide(AuthMiddlewareLive), - Effect.provide(overrides.sessionManager ?? makeSessionManagerLayer(makeCurrentUser())), Effect.provide(overrides.hazelSession ?? makeHazelSessionLayer()), Effect.provide( overrides.botRepo ?? makeBotRepoLayer(() => Effect.succeed(Option.none())), @@ -167,19 +156,32 @@ const runAuth = ( ) describe("AuthMiddlewareLive", () => { - it("authenticates JWT bearer tokens through SessionManager", async () => { - const currentUser = makeCurrentUser({ email: "jwt@example.com" }) + it("rejects a JWT-shaped bearer that is neither a session nor a bot token", async () => { const result = await runAuth(Headers.fromInput({ authorization: "Bearer a.b.c" }), { - sessionManager: makeSessionManagerLayer(currentUser), + hazelSession: makeHazelSessionLayer({ enabled: true }), }) - expect(Result.isSuccess(result)).toBe(true) - if (Result.isSuccess(result)) { - expect(Option.isSome(result.success)).toBe(true) - if (Option.isSome(result.success)) { - expect(result.success.value).toEqual(currentUser) - } - } + expect( + Result.isFailure(result) && ("_tag" in result.failure ? result.failure._tag : "unhandled"), + ).toBe("InvalidBearerTokenError") + }) + + it("reports a database failure during bot token lookup as SessionLoadError, not a 401", async () => { + const result = await runAuth(Headers.fromInput({ authorization: "Bearer hzl_bot_abc" }), { + botRepo: makeBotRepoLayer(() => + Effect.fail( + new Database.DatabaseError({ + type: "connection_error", + message: "connection refused", + cause: null, + }), + ), + ), + }) + + expect( + Result.isFailure(result) && ("_tag" in result.failure ? result.failure._tag : "unhandled"), + ).toBe("SessionLoadError") }) it("fails when no bearer token is provided", async () => { @@ -266,8 +268,11 @@ describe("AuthMiddlewareLive", () => { enabled: true, fromCookie: () => Effect.fail(new InvalidBearerTokenError({ message: "expired", detail: "expired" })), + verify: () => Effect.succeed(Option.some(makeCurrentUser())), + }) + const valid = await runAuth(Headers.fromInput({ authorization: "Bearer session-credential" }), { + hazelSession, }) - const valid = await runAuth(Headers.fromInput({ authorization: "Bearer a.b.c" }), { hazelSession }) expect(Result.isSuccess(valid)).toBe(true) const cookieOnly = await runAuth(Headers.fromInput({}), { hazelSession }) expect( diff --git a/apps/backend/src/rpc/middleware/auth.ts b/apps/backend/src/rpc/middleware/auth.ts index c97f1877b..a79c329ac 100644 --- a/apps/backend/src/rpc/middleware/auth.ts +++ b/apps/backend/src/rpc/middleware/auth.ts @@ -1,11 +1,15 @@ import { Headers } from "effect/http" import { BotRepo, UserRepo } from "@hazel/backend-core" -import { CurrentUser, InvalidBearerTokenError, SessionNotProvidedError } from "@hazel/domain" -import { Effect, Layer, Option } from "effect" +import { + CurrentUser, + InvalidBearerTokenError, + SessionLoadError, + SessionNotProvidedError, +} from "@hazel/domain" +import { Cause, Effect, Layer, Option } from "effect" import { AuthMiddleware } from "@hazel/domain/rpc" import { type ApiScope, CurrentBotScopes } from "@hazel/domain/scopes" import { HazelSession } from "../../services/hazel-session" -import { SessionManager } from "../../services/session-manager" export { AuthMiddleware } from "@hazel/domain/rpc" @@ -20,18 +24,9 @@ async function hashToken(token: string): Promise { return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") } -/** - * Check if a token looks like a JWT (three base64url-encoded segments) - */ -function isJwtToken(token: string): boolean { - const parts = token.split(".") - return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)) -} - export const AuthMiddlewareLive = Layer.effect( AuthMiddleware, Effect.gen(function* () { - const sessionManager = yield* SessionManager const hazelSession = yield* HazelSession const botRepo = yield* BotRepo const userRepo = yield* UserRepo @@ -55,16 +50,10 @@ export const AuthMiddlewareLive = Layer.effect( return yield* Effect.provideService(effect, CurrentUser.Context, cookieUser.value) } - // Bearer token: Clerk JWT, Hazel session credential (native clients) or bot token + // Bearer token: Hazel session credential (native clients) or bot token if (Option.isSome(authHeader) && authHeader.value.startsWith("Bearer ")) { const token = authHeader.value.slice(7) - // JWT → Clerk bearer auth. Otherwise, treat as bot token (hash lookup). - if (isJwtToken(token)) { - const currentUser = yield* sessionManager.authenticateWithBearer(token) - return yield* Effect.provideService(effect, CurrentUser.Context, currentUser) - } - if (hazelSession.enabled && !token.startsWith("hzl_bot_")) { const sessionUser = yield* hazelSession.verify(token) if (Option.isSome(sessionUser)) { @@ -81,12 +70,16 @@ export const AuthMiddlewareLive = Layer.effect( // Find bot by token hash const botOption = yield* botRepo.findByTokenHash(tokenHash).pipe( - Effect.catchTag("DatabaseError", () => - Effect.fail( - new InvalidBearerTokenError({ - message: "Failed to validate bot token", - detail: "Database error", - }), + Effect.catchTag("DatabaseError", (error) => + Effect.logError("Bot token lookup failed", Cause.fail(error)).pipe( + Effect.andThen( + Effect.fail( + new SessionLoadError({ + message: "Failed to validate bot token", + detail: "The session store is unavailable", + }), + ), + ), ), ), ) @@ -105,12 +98,16 @@ export const AuthMiddlewareLive = Layer.effect( // Get the bot's user from users table const userOption = yield* userRepo.findById(bot.userId).pipe( - Effect.catchTag("DatabaseError", () => - Effect.fail( - new InvalidBearerTokenError({ - message: "Failed to validate bot token", - detail: "Database error", - }), + Effect.catchTag("DatabaseError", (error) => + Effect.logError("Bot token lookup failed", Cause.fail(error)).pipe( + Effect.andThen( + Effect.fail( + new SessionLoadError({ + message: "Failed to validate bot token", + detail: "The session store is unavailable", + }), + ), + ), ), ), ) diff --git a/apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts b/apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts deleted file mode 100644 index 8d8d28c23..000000000 --- a/apps/backend/src/scripts/migrate-to-clerk/backfill-external-ids.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Cutover script — rewrites `users.externalId` from WorkOS user IDs to Clerk - * user IDs using the NDJSON mapping emitted by the clerk/migration-tool. - * - * Run this DURING the cutover window (app paused → run → deploy new code). - * See plan Phase G for the full runbook. - * - * Usage: - * DATABASE_URL=… bun run src/scripts/migrate-to-clerk/backfill-external-ids.ts - * DATABASE_URL=… bun run src/scripts/migrate-to-clerk/backfill-external-ids.ts --dry-run - * DATABASE_URL=… bun run src/scripts/migrate-to-clerk/backfill-external-ids.ts --force # skip mismatch check - * - * Input: - * ./exports/import-log.ndjson — each line: { userId: , status: "success", clerkUserId: } - * - * Idempotency: safe to re-run. The UPDATE only matches rows whose current - * externalId is still the old WorkOS ID. Once rewritten to the Clerk ID, - * subsequent runs become no-ops. - */ - -import { Database, PG, schema } from "@hazel/db" -import { Effect, Layer, Logger } from "effect" -import { DatabaseLive } from "../../services/database" - -const LOG_PATH = "./exports/import-log.ndjson" - -const args = new Set(process.argv.slice(2)) -const dryRun = args.has("--dry-run") -const force = args.has("--force") - -type LogEntry = { userId: string; status: string; clerkUserId?: string } - -const program = Effect.gen(function* () { - const db = yield* Database.Database - - const raw = yield* Effect.promise(() => Bun.file(LOG_PATH).text()) - const mapping: Array<{ workosId: string; clerkId: string }> = [] - for (const line of raw.split("\n")) { - if (!line.trim()) continue - const entry = JSON.parse(line) as LogEntry - if (entry.status === "success" && entry.clerkUserId) { - mapping.push({ workosId: entry.userId, clerkId: entry.clerkUserId }) - } - } - - yield* Effect.log(`Loaded ${mapping.length} WorkOS → Clerk ID mappings from ${LOG_PATH}`) - - // Pre-flight: count active users in the DB. If it's > mapping.length, some - // users have no Clerk equivalent (import failures or users created post-export). - const allActive = yield* db.run( - PG.from(schema.usersTable) - .select("id", "externalId") - .where(($) => [$.deletedAt.isNull()]), - ) - - yield* Effect.log(`Active users in DB: ${allActive.length}`) - - const workosIdSet = new Set(mapping.map((m) => m.workosId)) - const orphans = allActive.filter( - (u) => !workosIdSet.has(u.externalId) && !u.externalId.startsWith("user_") /* generic */, - ) - // A stricter orphan check: any active user whose externalId still matches the - // WorkOS shape but isn't in the mapping. - const workosShape = /^user_01[A-Z0-9]{24}$/ - const unmatched = allActive.filter( - (u) => workosShape.test(u.externalId) && !workosIdSet.has(u.externalId), - ) - - if (unmatched.length > 0) { - yield* Effect.logWarning( - `${unmatched.length} active users have WorkOS-shaped externalIds not present in the import log:`, - ) - for (const u of unmatched.slice(0, 10)) { - yield* Effect.logWarning(` - user.id=${u.id} externalId=${u.externalId}`) - } - if (!force) { - yield* Effect.fail( - new Error( - `${unmatched.length} unmatched users. Re-run delta export + import, or pass --force to proceed anyway.`, - ), - ) - } - } - - if (dryRun) { - yield* Effect.log(`[dry-run] Would UPDATE ${mapping.length} rows. No changes made.`) - return - } - - // Apply updates in a single transaction. Each UPDATE matches by the current - // externalId (= WorkOS ID); if a row has already been rewritten, the WHERE - // fails to match and the UPDATE is a no-op. Only touches non-deleted rows. - yield* Effect.log(`Applying ${mapping.length} updates…`) - - const updated = yield* db.transaction( - Effect.gen(function* () { - let touched = 0 - for (const { workosId, clerkId } of mapping) { - const result = yield* db.run( - PG.update(schema.usersTable) - .set({ externalId: clerkId, updatedAt: new Date() }) - .where(($) => [$.externalId.eq(workosId), $.deletedAt.isNull()]) - .returning("id"), - ) - touched += result.length - } - return touched - }), - ) - - yield* Effect.log(`Updated ${updated} rows.`) - - // Post-flight sanity check. - const remaining = yield* db.run( - PG.from(schema.usersTable) - .select(($) => ({ count: $.id })) - .where(($) => [$.deletedAt.isNull()]), - ) - const clerkShape = /^user_[A-Za-z0-9]{27}$/ - const afterAll = yield* db.run( - PG.from(schema.usersTable) - .select("id", "externalId") - .where(($) => [$.deletedAt.isNull()]), - ) - - const stillWorkOS = afterAll.filter((u) => workosShape.test(u.externalId)) - const properlyClerk = afterAll.filter((u) => clerkShape.test(u.externalId)) - - yield* Effect.log( - `Post-migration: total=${remaining.length} clerk-shaped=${properlyClerk.length} still-workos=${stillWorkOS.length}`, - ) - - if (stillWorkOS.length > 0) { - yield* Effect.logWarning( - `${stillWorkOS.length} users still have WorkOS-shaped externalIds — investigate before re-opening writes.`, - ) - } -}).pipe(Effect.provide(DatabaseLive), Effect.provide(Logger.layer([Logger.consolePretty()]))) - -Effect.runPromise(program as Effect.Effect) diff --git a/apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts b/apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts deleted file mode 100644 index 9237f0890..000000000 --- a/apps/backend/src/scripts/migrate-to-clerk/diagnose-org-duplicates.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Find orgs that appear to be duplicated after the Clerk migration. - * Groups active orgs by lowercased name. For each group with >1 row, - * prints slug, logoUrl, clerkOrganizationId (from settings), member count, - * channel count, and createdAt so we can see the canonical row vs. the ghost. - */ - -import { Database, PG, schema } from "@hazel/db" -import { Effect, Logger } from "effect" -import { DatabaseLive } from "../../services/database" - -const program = Effect.gen(function* () { - const db = yield* Database.Database - - const orgs = yield* db.run( - PG.from(schema.organizationsTable) - .select("id", "name", "slug", "logoUrl", "settings", "createdAt") - .where(($) => [$.deletedAt.isNull()]), - ) - - yield* Effect.log(`Total active orgs: ${orgs.length}`) - - const byName = new Map>() - for (const o of orgs) { - const key = o.name.trim().toLowerCase() - const list = byName.get(key) ?? [] - list.push(o) - byName.set(key, list) - } - - const dupes = [...byName.entries()].filter(([_, list]) => list.length > 1) - yield* Effect.log(`Name groups with >1 row: ${dupes.length}`) - - for (const [name, list] of dupes) { - yield* Effect.log(`\n=== "${name}" — ${list.length} rows ===`) - for (const o of list.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())) { - const memberCount = yield* db.run( - PG.from(schema.organizationMembersTable) - .select(() => ({ n: PG.count() })) - .where(($) => [$.organizationId.eq(o.id), $.deletedAt.isNull()]), - ) - - const channelCount = yield* db.run( - PG.from(schema.channelsTable) - .select(() => ({ n: PG.count() })) - .where(($) => [$.organizationId.eq(o.id), $.deletedAt.isNull()]), - ) - - const messageCount = yield* db.run( - PG.from(schema.messagesTable, "m") - .innerJoin(schema.channelsTable, "c", (m, c) => c.id.eq(m.channelId)) - .select(() => ({ n: PG.count() })) - .where(($) => [$.c.organizationId.eq(o.id)]), - ) - - const clerkOrgId = (o.settings as any)?.clerkOrganizationId ?? "(none)" - yield* Effect.log( - ` id=${o.id} slug=${o.slug} logoUrl=${o.logoUrl ? "set" : "null"} clerkOrgId=${clerkOrgId} members=${memberCount[0]!.n} channels=${channelCount[0]!.n} messages=${messageCount[0]!.n} created=${o.createdAt.toISOString()}`, - ) - } - } -}).pipe(Effect.provide(DatabaseLive), Effect.provide(Logger.layer([Logger.consolePretty()]))) - -Effect.runPromise(program as Effect.Effect) diff --git a/apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts b/apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts deleted file mode 100644 index dc1a049f7..000000000 --- a/apps/backend/src/scripts/migrate-to-clerk/merge-org-duplicates.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Merge Clerk-webhook-created ghost orgs back into the original WorkOS-era rows. - * - * Criteria for a "ghost" row (what we delete): - * - settings.clerkOrganizationId IS set (came from Clerk webhook insert) - * - 0 channels AND 0 messages (no real data on it) - * - * Criteria for the "canonical" row (what we keep): - * - Same lowercased name as the ghost - * - Has channels or messages (i.e. the real org) - * - Its clerkOrganizationId is NULL or matches the ghost's - * (NULL = pre-migration WorkOS row; match = a previous merge / webhook back-link - * already stamped it. A different non-NULL value means the row is bound to a - * different Clerk org — skip, that's not a duplicate, that's two real orgs.) - * - * If multiple canonical candidates exist for one ghost, we skip that group - * and log a warning — needs manual review. If the ghost has non-zero data - * we skip too (the "obvious" merge is unsafe). - * - * The merge itself: - * - Sets canonical.settings.clerkOrganizationId = ghost's - * - If canonical.logoUrl is null, copies the ghost's logoUrl across - * - Deletes ghost's organization_members rows (webhook will recreate them - * now that canonical has the Clerk org ID) - * - Hard-deletes the ghost org row - * - * Usage: - * IS_DEV=false bun run src/scripts/migrate-to-clerk/merge-org-duplicates.ts --dry-run - * IS_DEV=false bun run src/scripts/migrate-to-clerk/merge-org-duplicates.ts - */ - -import { Database, PG, schema } from "@hazel/db" -import { Effect, Logger, Schema } from "effect" -import { DatabaseLive } from "../../services/database" - -const dryRun = process.argv.includes("--dry-run") - -const program = Effect.gen(function* () { - const db = yield* Database.Database - - const orgs = yield* db.run( - PG.from(schema.organizationsTable) - .select("id", "name", "slug", "logoUrl", "settings", "createdAt") - .where(($) => [$.deletedAt.isNull()]), - ) - - // Count channels + messages per org in one pass. - const counts = yield* db.query( - Database.sql` - select - o.id as org_id, - (select count(*)::int from channels where "organizationId" = o.id and "deletedAt" is null) as channel_count, - (select count(*)::int from messages m join channels c on c.id = m."channelId" where c."organizationId" = o.id) as message_count, - (select count(*)::int from organization_members where "organizationId" = o.id and "deletedAt" is null) as member_count - from organizations o - where o."deletedAt" is null - `, - Schema.Struct({ - org_id: Schema.String, - channel_count: Schema.Number, - message_count: Schema.Number, - member_count: Schema.Number, - }), - ) - - const statsById = new Map(counts.map((c) => [c.org_id, c])) - - type Enriched = (typeof orgs)[number] & { - channelCount: number - messageCount: number - memberCount: number - clerkOrgId: string | null - } - - const enriched: Enriched[] = orgs.map((o) => { - const stats = statsById.get(o.id) ?? { channel_count: 0, message_count: 0, member_count: 0 } - const clerkOrgId = ((o.settings as any)?.clerkOrganizationId ?? null) as string | null - return { - ...o, - channelCount: stats.channel_count, - messageCount: stats.message_count, - memberCount: stats.member_count, - clerkOrgId, - } - }) - - const byName = new Map() - for (const o of enriched) { - const key = o.name.trim().toLowerCase() - const list = byName.get(key) ?? [] - list.push(o) - byName.set(key, list) - } - - type Merge = { canonical: Enriched; ghost: Enriched } - const merges: Merge[] = [] - const skipped: Array<{ name: string; reason: string }> = [] - - for (const [name, list] of byName) { - if (list.length < 2) continue - - const ghosts = list.filter( - (o) => o.clerkOrgId !== null && o.channelCount === 0 && o.messageCount === 0, - ) - if (ghosts.length === 0) continue - - for (const ghost of ghosts) { - const canonicalCandidates = list.filter( - (o) => - o.id !== ghost.id && - (o.channelCount > 0 || o.messageCount > 0) && - (o.clerkOrgId === null || o.clerkOrgId === ghost.clerkOrgId), - ) - if (canonicalCandidates.length === 0) { - skipped.push({ - name, - reason: `no row with channels/messages and matching (or null) clerkOrganizationId to merge ghost ${ghost.id} into`, - }) - continue - } - if (canonicalCandidates.length > 1) { - skipped.push({ - name, - reason: `${canonicalCandidates.length} candidate canonical rows — manual merge required`, - }) - continue - } - merges.push({ canonical: canonicalCandidates[0]!, ghost }) - } - } - - yield* Effect.log( - `Orgs: ${orgs.length} | Groups with dupes: ${ - [...byName.values()].filter((l) => l.length > 1).length - } | Planned merges: ${merges.length} | Skipped: ${skipped.length}`, - ) - - for (const { canonical, ghost } of merges) { - yield* Effect.log( - ` MERGE "${canonical.name}": keep id=${canonical.id} slug=${canonical.slug} (channels=${canonical.channelCount} messages=${canonical.messageCount} members=${canonical.memberCount}) <- ghost id=${ghost.id} slug=${ghost.slug} clerkOrgId=${ghost.clerkOrgId} members=${ghost.memberCount} logoUrl=${ghost.logoUrl ? "set" : "null"}`, - ) - } - - for (const s of skipped) { - yield* Effect.logWarning(` SKIP "${s.name}": ${s.reason}`) - } - - if (merges.length === 0) { - yield* Effect.log("Nothing to merge.") - return - } - - if (dryRun) { - yield* Effect.log(`[dry-run] No changes made.`) - return - } - - yield* Effect.log(`\nApplying ${merges.length} merges…`) - yield* db.transaction( - Effect.gen(function* () { - for (const { canonical, ghost } of merges) { - const mergedSettings = { - ...((canonical.settings as any) ?? {}), - clerkOrganizationId: ghost.clerkOrgId, - } - - yield* db.run( - PG.update(schema.organizationsTable) - .set({ - settings: mergedSettings, - ...(!canonical.logoUrl && ghost.logoUrl ? { logoUrl: ghost.logoUrl } : {}), - updatedAt: new Date(), - }) - .where(($) => [$.id.eq(canonical.id)]), - ) - - // Remove the ghost's membership rows — the next webhook fire on the - // canonical (now correctly keyed by clerkOrgId) will re-sync real members. - yield* db.run( - PG.deleteFrom(schema.organizationMembersTable).where(($) => [ - $.organizationId.eq(ghost.id), - ]), - ) - - // Drop the ghost row itself (hard delete — it has no channels/messages). - yield* db.run(PG.deleteFrom(schema.organizationsTable).where(($) => [$.id.eq(ghost.id)])) - - yield* Effect.log(` ✓ merged ghost ${ghost.id} into ${canonical.id} (${canonical.name})`) - } - }), - ) - - yield* Effect.log(`\nDone. Merged ${merges.length} org(s).`) -}).pipe(Effect.provide(DatabaseLive), Effect.provide(Logger.layer([Logger.consolePretty()]))) - -Effect.runPromise(program as Effect.Effect) diff --git a/apps/backend/src/services/auth.ts b/apps/backend/src/services/auth.ts index a8ed7a6c8..bac69404d 100644 --- a/apps/backend/src/services/auth.ts +++ b/apps/backend/src/services/auth.ts @@ -1,21 +1,13 @@ -import { CurrentUser } from "@hazel/domain" +import { CurrentUser, InvalidBearerTokenError } from "@hazel/domain" import { Effect, Layer, Option, Redacted } from "effect" import { HttpServerRequest } from "effect/http" import { HazelSession } from "./hazel-session" -import { SessionManager } from "./session-manager" - -/** Three base64url segments: a Clerk session JWT rather than a Hazel session credential. */ -const isJwt = (token: string) => { - const parts = token.split(".") - return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)) -} export const AuthorizationLive = Layer.effect( CurrentUser.Authorization, Effect.gen(function* () { yield* Effect.logDebug("Initializing Authorization middleware...") - const sessionManager = yield* SessionManager const hazelSession = yield* HazelSession return CurrentUser.Authorization.of({ @@ -36,20 +28,21 @@ export const AuthorizationLive = Layer.effect( } // Hazel session credential (native clients) - if (hazelSession.enabled && token !== "" && !isJwt(token)) { - const sessionUser = yield* hazelSession.verify(token) - if (Option.isSome(sessionUser)) { - return yield* Effect.provideService( - httpEffect, - CurrentUser.Context, - sessionUser.value, - ) - } + const sessionUser = token === "" ? Option.none() : yield* hazelSession.verify(token) + if (Option.isSome(sessionUser)) { + return yield* Effect.provideService( + httpEffect, + CurrentUser.Context, + sessionUser.value, + ) } - // Clerk session JWT - const user = yield* sessionManager.authenticateWithBearer(token) - return yield* Effect.provideService(httpEffect, CurrentUser.Context, user) + return yield* Effect.fail( + new InvalidBearerTokenError({ + message: "Invalid session", + detail: token === "" ? "No credential provided" : "Session expired or revoked", + }), + ) }), }) }), diff --git a/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts b/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts index d5b1b52c4..48b5e6ae1 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-core-worker.integration.test.ts @@ -28,13 +28,17 @@ import type { UserId, } from "@hazel/schema" import { Discord } from "@hazel/integrations" -import { Effect, Layer } from "effect" +import { Effect, Layer, Result } from "effect" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../../test/chat-sync-db-harness" import { recordChatSyncDiagnostic } from "../../test/chat-sync-test-diagnostics" import { ChannelAccessSyncService } from "../channel-access-sync" import { IntegrationBotService } from "../integrations/integration-bot-service" -import { ChatSyncProviderRegistry, type ChatSyncProviderAdapter } from "./chat-sync-provider-registry" +import { + ChatSyncProviderApiError, + ChatSyncProviderRegistry, + type ChatSyncProviderAdapter, +} from "./chat-sync-provider-registry" import { ChatSyncCoreWorker, ChatSyncCoreWorkerMake } from "./chat-sync-core-worker" type SeedContext = { @@ -516,6 +520,97 @@ describe("ChatSyncCoreWorker integration", () => { ) }) + it("fails a retryable delivery and releases its receipt so the outbox retry delivers it", async () => { + const ctx = await insertBaseContext(harness) + const recorder = makeAdapterRecorder() + let attempts = 0 + const flakyAdapter: ChatSyncProviderAdapter = { + ...recorder.adapter, + createMessage: (params) => { + attempts += 1 + return attempts === 1 + ? Effect.fail( + new ChatSyncProviderApiError({ + provider: "discord", + message: "unavailable", + status: 503, + }), + ) + : recorder.adapter.createMessage(params) + }, + } + const workerLayer = makeWorkerLayer(harness, { + botUserId: ctx.botUserId, + providerAdapter: flakyAdapter, + }) + const syncConnectionId = await insertConnection(harness, { + organizationId: ctx.organizationId, + createdBy: ctx.authorUserId, + }) + await insertLink(harness, { + syncConnectionId, + hazelChannelId: ctx.channelId, + externalChannelId: "11111111111111111" as ExternalChannelId, + }) + const messageId = await insertMessage(harness, { + channelId: ctx.channelId, + authorId: ctx.authorUserId, + content: "retry me", + }) + const deliver = Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageCreateToAllConnections("discord", messageId, "outbox-key") + }).pipe(Effect.provide(workerLayer)) + + const first = await runEffect(Effect.result(deliver)) + expect(Result.isFailure(first)).toBe(true) + + const second = await runEffect(deliver) + expect(second.synced).toBe(1) + expect(recorder.calls.createMessage).toHaveLength(1) + }) + + it("does not fail the delivery for a non-retryable provider error", async () => { + const ctx = await insertBaseContext(harness) + const recorder = makeAdapterRecorder() + const workerLayer = makeWorkerLayer(harness, { + botUserId: ctx.botUserId, + providerAdapter: { + ...recorder.adapter, + createMessage: () => + Effect.fail( + new ChatSyncProviderApiError({ + provider: "discord", + message: "forbidden", + status: 403, + }), + ), + }, + }) + const syncConnectionId = await insertConnection(harness, { + organizationId: ctx.organizationId, + createdBy: ctx.authorUserId, + }) + await insertLink(harness, { + syncConnectionId, + hazelChannelId: ctx.channelId, + externalChannelId: "11111111111111111" as ExternalChannelId, + }) + const messageId = await insertMessage(harness, { + channelId: ctx.channelId, + authorId: ctx.authorUserId, + content: "no retry", + }) + + const result = await runEffect( + Effect.gen(function* () { + const w = yield* ChatSyncCoreWorker + return yield* w.syncHazelMessageCreateToAllConnections("discord", messageId, "outbox-key") + }).pipe(Effect.provide(workerLayer)), + ) + expect(result.failed).toBe(1) + }) + it("syncs Hazel reactions and ignores delete while reaction still exists", async () => { const ctx = await insertBaseContext(harness) const recorder = makeAdapterRecorder() diff --git a/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts b/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts index f50b3aa4c..40f215084 100644 --- a/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts +++ b/apps/backend/src/services/chat-sync/chat-sync-core-worker.ts @@ -28,10 +28,10 @@ import { ExternalWebhookId, ExternalThreadId, } from "@hazel/schema" -import { Context, Config, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Cause, Context, Config, Effect, Layer, Option, Redacted, Schema } from "effect" import { ChannelAccessSyncService } from "../channel-access-sync" import { IntegrationBotService } from "../integrations/integration-bot-service" -import { ChatSyncProviderRegistry } from "./chat-sync-provider-registry" +import { ChatSyncProviderApiError, ChatSyncProviderRegistry } from "./chat-sync-provider-registry" import { Discord } from "@hazel/integrations" import { type ChatSyncAttachmentLink, @@ -77,6 +77,48 @@ export class DiscordSyncApiError extends Schema.TaggedError detail: Schema.optional(Schema.String), }) {} +/** + * Some Hazel → provider deliveries failed in a way that may succeed later. Failing with it + * makes the message outbox retry the event; the deliveries that went through are deduped by + * their receipts on the retry. + */ +export class ChatSyncDeliveryRetryableError extends Schema.TaggedError()( + "ChatSyncDeliveryRetryableError", + { + provider: Schema.String, + failed: Schema.Number, + cause: Schema.Defect(), + }, +) {} + +/** + * Whether a failed Hazel → provider delivery may succeed on another attempt: database blips, + * provider transport failures and timeouts, rate limits and 5xx. Missing links, configuration + * problems and other 4xx fail the same way every time. + */ +export const isRetryableSyncFailure = (error: unknown): boolean => { + if (error instanceof Database.DatabaseError) return true + if (error instanceof ChatSyncProviderApiError || error instanceof DiscordSyncApiError) { + const status = error.status + return status === undefined || status === 408 || status === 429 || status >= 500 + } + return false +} + +/** The receipt keys Hazel → provider deliveries claim, unless the caller passes its own. */ +const hazelDedupeKey = { + messageCreate: (id: MessageId, override?: string) => override ?? `hazel:message:create:${id}`, + messageUpdate: (id: MessageId, override?: string) => override ?? `hazel:message:update:${id}`, + messageDelete: (id: MessageId, override?: string) => override ?? `hazel:message:delete:${id}`, + reactionCreate: (id: MessageReactionId, override?: string) => override ?? `hazel:reaction:create:${id}`, + reactionDelete: ( + payload: { hazelMessageId: MessageId; emoji: string; userId?: UserId }, + override?: string, + ) => + override ?? + `hazel:reaction:delete:${payload.hazelMessageId}:${payload.emoji}:${payload.userId ?? "unknown"}`, +} + type ChatSyncProvider = ChatSyncConnection.ChatSyncProvider export interface ChatSyncIngressMessageAttachment extends ChatSyncAttachmentLink { @@ -215,6 +257,32 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk }) }) + /** + * A delivery claims its receipt before calling the provider, so a failure would leave it + * claimed and every retry would be deduped. On a retryable failure the claim is dropped + * so the outbox's retry delivers it. The outbox dispatcher is single-flight, so no other + * worker holds the same key. + */ + const releaseClaimOnRetryableFailure = + (syncConnectionId: SyncConnectionId, dedupeKey: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.tapError((error) => + isRetryableSyncFailure(error) + ? eventReceiptRepo + .releaseByDedupeKey({ syncConnectionId, source: "hazel", dedupeKey }) + .pipe( + Effect.catchTag("DatabaseError", (releaseError) => + Effect.logWarning( + "Failed to release chat sync receipt; the retry will be deduped", + Cause.fail(releaseError), + ), + ), + ) + : Effect.void, + ), + ) + const getProviderAdapter = Effect.fn("ChatSyncCoreWorker.getProviderAdapter")(function* ( provider: ChatSyncProvider, ) { @@ -311,6 +379,12 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk ) const decodeProvider = Schema.decodeUnknownSync(IntegrationConnection.IntegrationProvider) + const decodeOutboundIdentitySettings = Schema.decodeUnknownOption( + ChatSyncChannelLink.OutboundIdentitySettings, + ) + const decodeDiscordWebhookConfig = Schema.decodeUnknownOption( + ChatSyncChannelLink.DiscordWebhookOutboundIdentityConfig, + ) type WebhookPermissionStatus = "unknown" | "allowed" | "denied" @@ -355,11 +429,6 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk ...(params.reason ? { reason: params.reason } : {}), }) - const isDiscordApiError = (error: unknown): error is Discord.DiscordApiError => - typeof error === "object" && - error !== null && - (error as { _tag?: unknown })._tag === "DiscordApiError" - const isWebhookStrategyEnabled = ( outboundIdentity: ChatSyncChannelLink.OutboundIdentitySettings, ): boolean => outboundIdentity.enabled && outboundIdentity.strategy === "webhook" @@ -387,11 +456,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk if (raw === undefined) { return defaultOutboundIdentitySettings() } - try { - return Schema.decodeUnknownSync(ChatSyncChannelLink.OutboundIdentitySettings)(raw) - } catch { - return defaultOutboundIdentitySettings() - } + return Option.getOrElse(decodeOutboundIdentitySettings(raw), defaultOutboundIdentitySettings) } const getDiscordWebhookConfig = ( @@ -410,15 +475,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk return Option.none() } - try { - return Option.some( - Schema.decodeUnknownSync(ChatSyncChannelLink.DiscordWebhookOutboundIdentityConfig)( - providerConfig, - ), - ) - } catch { - return Option.none() - } + return decodeDiscordWebhookConfig(providerConfig) } const shouldIgnoreWebhookOrigin = ( @@ -536,9 +593,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk ) return Option.some(nextConfig) }).pipe( - Effect.catch((error) => + // Discord refusing or failing the webhook leaves the bot identity in place; database + // failures propagate so the delivery is retried. + Effect.catchTag("DiscordApiError", (error) => Effect.gen(function* () { - if (isDiscordApiError(error) && error.status === 403) { + if (error.status === 403) { const fallbackOutboundIdentity: ChatSyncChannelLink.OutboundIdentitySettings = { enabled: outboundIdentity.enabled, @@ -555,23 +614,21 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk }), ) } else { - const reason = - typeof error === "object" && error !== null && "message" in error - ? String((error as { message?: unknown }).message) - : undefined - yield* persistWebhookIdentity( params.link, outboundIdentity, - makeWebhookPermissionState({ status: "unknown", reason }), + makeWebhookPermissionState({ status: "unknown", reason: error.message }), ) } - yield* Effect.logWarning("Failed to provision Discord webhook identity", { - provider: params.provider, - externalChannelId: params.link.externalChannelId, - error: String(error), - }) + yield* Effect.logWarning( + "Failed to provision Discord webhook identity", + Cause.fail(error), + { + provider: params.provider, + externalChannelId: params.link.externalChannelId, + }, + ) return Option.none() }), ), @@ -642,13 +699,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk return Option.some(outboundMessageId as ExternalMessageId) }).pipe( - Effect.catch((error) => - Effect.gen(function* () { - yield* Effect.logWarning("Discord webhook send failed; falling back to bot API", { - error: String(error), - }) - return Option.none() - }), + Effect.catchTag("DiscordApiError", (error) => + Effect.logWarning( + "Discord webhook send failed; falling back to bot API", + Cause.fail(error), + ).pipe(Effect.as(Option.none())), ), ) }, @@ -687,16 +742,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk return true }).pipe( - Effect.catch((error) => - Effect.gen(function* () { - yield* Effect.logWarning( - "Discord webhook update failed; falling back to bot API", - { - error: String(error), - }, - ) - return false - }), + Effect.catchTag("DiscordApiError", (error) => + Effect.logWarning( + "Discord webhook update failed; falling back to bot API", + Cause.fail(error), + ).pipe(Effect.as(false)), ), ) }, @@ -733,16 +783,11 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk return true }).pipe( - Effect.catch((error) => - Effect.gen(function* () { - yield* Effect.logWarning( - "Discord webhook delete failed; falling back to bot API", - { - error: String(error), - }, - ) - return false - }), + Effect.catchTag("DiscordApiError", (error) => + Effect.logWarning( + "Discord webhook delete failed; falling back to bot API", + Cause.fail(error), + ).pipe(Effect.as(false)), ), ) }, @@ -1052,7 +1097,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk hazelMessageId: MessageId, dedupeKeyOverride?: string, ) { - const dedupeKey = dedupeKeyOverride ?? `hazel:message:create:${hazelMessageId}` + const dedupeKey = hazelDedupeKey.messageCreate(hazelMessageId, dedupeKeyOverride) const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) if (!claimed) { return { status: "deduped" as const } @@ -1196,6 +1241,16 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk return { status: "synced" as const, externalMessageId } }, + ( + effect, + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) => + releaseClaimOnRetryableFailure( + syncConnectionId, + hazelDedupeKey.messageCreate(hazelMessageId, dedupeKeyOverride), + )(effect), ) const syncConnection = Effect.fn("discordSyncWorker.syncConnection")(function* ( @@ -1272,395 +1327,448 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const syncHazelMessageUpdateToProvider = Effect.fn( "discordSyncWorker.syncHazelMessageUpdateToProvider", - )(function* ( - syncConnectionId: SyncConnectionId, - hazelMessageId: MessageId, - dedupeKeyOverride?: string, - ) { - const dedupeKey = dedupeKeyOverride ?? `hazel:message:update:${hazelMessageId}` - const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) - if (!claimed) { - return { status: "deduped" as const } - } - - const connectionOption = yield* connectionRepo.findById(syncConnectionId) - if (Option.isNone(connectionOption)) { - return yield* Effect.fail( - new DiscordSyncConnectionNotFoundError({ - syncConnectionId, - }), - ) - } - const connection = connectionOption.value - const adapter = yield* getProviderAdapter(connection.provider) - - const messageOption = yield* messageRepo.findById(hazelMessageId) - if (Option.isNone(messageOption)) { - return yield* Effect.fail(new DiscordSyncMessageNotFoundError({ messageId: hazelMessageId })) - } - const message = messageOption.value + )( + function* ( + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) { + const dedupeKey = hazelDedupeKey.messageUpdate(hazelMessageId, dedupeKeyOverride) + const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) + if (!claimed) { + return { status: "deduped" as const } + } - const link = yield* resolveOrCreateOutboundLinkForMessage({ - syncConnectionId, - provider: connection.provider, - hazelChannelId: message.channelId, - }) - const normalizedLink = normalizeChannelLinkExternalId(link) + const connectionOption = yield* connectionRepo.findById(syncConnectionId) + if (Option.isNone(connectionOption)) { + return yield* Effect.fail( + new DiscordSyncConnectionNotFoundError({ + syncConnectionId, + }), + ) + } + const connection = connectionOption.value + const adapter = yield* getProviderAdapter(connection.provider) - const messageLinkOption = yield* messageLinkRepo.findByHazelMessage(link.id, hazelMessageId) + const messageOption = yield* messageRepo.findById(hazelMessageId) + if (Option.isNone(messageOption)) { + return yield* Effect.fail( + new DiscordSyncMessageNotFoundError({ messageId: hazelMessageId }), + ) + } + const message = messageOption.value - if (Option.isNone(messageLinkOption)) { - yield* writeReceipt({ + const link = yield* resolveOrCreateOutboundLinkForMessage({ syncConnectionId, - channelLinkId: link.id, - source: "hazel", - dedupeKey, - status: "ignored", - payload: { hazelMessageId }, + provider: connection.provider, + hazelChannelId: message.channelId, }) - return { status: "ignored_missing_link" as const } - } - const messageLink = messageLinkOption.value - const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) - const outboundContent = - connection.provider === "discord" - ? yield* translateHazelMentionsForDiscord({ - organizationId: connection.organizationId, - content: message.content, - }) - : message.content + const normalizedLink = normalizeChannelLinkExternalId(link) - if (connection.provider !== "discord") { - yield* adapter.updateMessage({ - externalChannelId: normalizedLink.externalChannelId, - externalMessageId: normalizedMessageLink.externalMessageId, - content: outboundContent, - }) - } else { - const updated = yield* updateDiscordMessageViaWebhook({ - link: { - id: normalizedLink.id, - externalChannelId: normalizedLink.externalChannelId, - settings: link.settings, - }, - externalMessageId: normalizedMessageLink.externalMessageId, - content: outboundContent, - }) + const messageLinkOption = yield* messageLinkRepo.findByHazelMessage(link.id, hazelMessageId) + + if (Option.isNone(messageLinkOption)) { + yield* writeReceipt({ + syncConnectionId, + channelLinkId: link.id, + source: "hazel", + dedupeKey, + status: "ignored", + payload: { hazelMessageId }, + }) + return { status: "ignored_missing_link" as const } + } + const messageLink = messageLinkOption.value + const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) + const outboundContent = + connection.provider === "discord" + ? yield* translateHazelMentionsForDiscord({ + organizationId: connection.organizationId, + content: message.content, + }) + : message.content - if (!updated) { + if (connection.provider !== "discord") { yield* adapter.updateMessage({ externalChannelId: normalizedLink.externalChannelId, externalMessageId: normalizedMessageLink.externalMessageId, content: outboundContent, }) + } else { + const updated = yield* updateDiscordMessageViaWebhook({ + link: { + id: normalizedLink.id, + externalChannelId: normalizedLink.externalChannelId, + settings: link.settings, + }, + externalMessageId: normalizedMessageLink.externalMessageId, + content: outboundContent, + }) + + if (!updated) { + yield* adapter.updateMessage({ + externalChannelId: normalizedLink.externalChannelId, + externalMessageId: normalizedMessageLink.externalMessageId, + content: outboundContent, + }) + } } - } - yield* messageLinkRepo.updateLastSyncedAt(messageLink.id) - yield* writeReceipt({ - syncConnectionId, - channelLinkId: link.id, - source: "hazel", - dedupeKey, - payload: { - hazelMessageId, - externalMessageId: normalizedMessageLink.externalMessageId, - }, - }) - yield* connectionRepo.updateLastSyncedAt(syncConnectionId) - yield* channelLinkRepo.updateLastSyncedAt(link.id) + yield* messageLinkRepo.updateLastSyncedAt(messageLink.id) + yield* writeReceipt({ + syncConnectionId, + channelLinkId: link.id, + source: "hazel", + dedupeKey, + payload: { + hazelMessageId, + externalMessageId: normalizedMessageLink.externalMessageId, + }, + }) + yield* connectionRepo.updateLastSyncedAt(syncConnectionId) + yield* channelLinkRepo.updateLastSyncedAt(link.id) - return { - status: "updated" as const, - externalMessageId: normalizedMessageLink.externalMessageId, - } - }) + return { + status: "updated" as const, + externalMessageId: normalizedMessageLink.externalMessageId, + } + }, + ( + effect, + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) => + releaseClaimOnRetryableFailure( + syncConnectionId, + hazelDedupeKey.messageUpdate(hazelMessageId, dedupeKeyOverride), + )(effect), + ) const syncHazelMessageDeleteToProvider = Effect.fn( "discordSyncWorker.syncHazelMessageDeleteToProvider", - )(function* ( - syncConnectionId: SyncConnectionId, - hazelMessageId: MessageId, - dedupeKeyOverride?: string, - ) { - const dedupeKey = dedupeKeyOverride ?? `hazel:message:delete:${hazelMessageId}` - const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) - if (!claimed) { - return { status: "deduped" as const } - } - - const connectionOption = yield* connectionRepo.findById(syncConnectionId) - if (Option.isNone(connectionOption)) { - return yield* Effect.fail( - new DiscordSyncConnectionNotFoundError({ - syncConnectionId, - }), - ) - } - const connection = connectionOption.value - const adapter = yield* getProviderAdapter(connection.provider) - - const messageOption = yield* messageRepo.findById(hazelMessageId) - if (Option.isNone(messageOption)) { - return yield* Effect.fail(new DiscordSyncMessageNotFoundError({ messageId: hazelMessageId })) - } - const message = messageOption.value + )( + function* ( + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) { + const dedupeKey = hazelDedupeKey.messageDelete(hazelMessageId, dedupeKeyOverride) + const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) + if (!claimed) { + return { status: "deduped" as const } + } - const link = yield* resolveOrCreateOutboundLinkForMessage({ - syncConnectionId, - provider: connection.provider, - hazelChannelId: message.channelId, - }) - const normalizedLink = normalizeChannelLinkExternalId(link) + const connectionOption = yield* connectionRepo.findById(syncConnectionId) + if (Option.isNone(connectionOption)) { + return yield* Effect.fail( + new DiscordSyncConnectionNotFoundError({ + syncConnectionId, + }), + ) + } + const connection = connectionOption.value + const adapter = yield* getProviderAdapter(connection.provider) - const messageLinkOption = yield* messageLinkRepo.findByHazelMessage(link.id, hazelMessageId) + const messageOption = yield* messageRepo.findById(hazelMessageId) + if (Option.isNone(messageOption)) { + return yield* Effect.fail( + new DiscordSyncMessageNotFoundError({ messageId: hazelMessageId }), + ) + } + const message = messageOption.value - if (Option.isNone(messageLinkOption)) { - yield* writeReceipt({ + const link = yield* resolveOrCreateOutboundLinkForMessage({ syncConnectionId, - channelLinkId: link.id, - source: "hazel", - dedupeKey, - status: "ignored", - payload: { hazelMessageId }, + provider: connection.provider, + hazelChannelId: message.channelId, }) - return { status: "ignored_missing_link" as const } - } - const messageLink = messageLinkOption.value - const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) + const normalizedLink = normalizeChannelLinkExternalId(link) - if (connection.provider !== "discord") { - yield* adapter.deleteMessage({ - externalChannelId: normalizedLink.externalChannelId, - externalMessageId: normalizedMessageLink.externalMessageId, - }) - } else { - const deleted = yield* deleteDiscordMessageViaWebhook({ - link: { - id: normalizedLink.id, - externalChannelId: normalizedLink.externalChannelId, - settings: link.settings, - }, - externalMessageId: normalizedMessageLink.externalMessageId, - }) + const messageLinkOption = yield* messageLinkRepo.findByHazelMessage(link.id, hazelMessageId) + + if (Option.isNone(messageLinkOption)) { + yield* writeReceipt({ + syncConnectionId, + channelLinkId: link.id, + source: "hazel", + dedupeKey, + status: "ignored", + payload: { hazelMessageId }, + }) + return { status: "ignored_missing_link" as const } + } + const messageLink = messageLinkOption.value + const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) - if (!deleted) { + if (connection.provider !== "discord") { yield* adapter.deleteMessage({ externalChannelId: normalizedLink.externalChannelId, externalMessageId: normalizedMessageLink.externalMessageId, }) - } - } + } else { + const deleted = yield* deleteDiscordMessageViaWebhook({ + link: { + id: normalizedLink.id, + externalChannelId: normalizedLink.externalChannelId, + settings: link.settings, + }, + externalMessageId: normalizedMessageLink.externalMessageId, + }) - yield* messageLinkRepo.softDelete(messageLink.id) - yield* writeReceipt({ - syncConnectionId, - channelLinkId: link.id, - source: "hazel", - dedupeKey, - payload: { - hazelMessageId, - externalMessageId: normalizedMessageLink.externalMessageId, - }, - }) - yield* connectionRepo.updateLastSyncedAt(syncConnectionId) - yield* channelLinkRepo.updateLastSyncedAt(link.id) + if (!deleted) { + yield* adapter.deleteMessage({ + externalChannelId: normalizedLink.externalChannelId, + externalMessageId: normalizedMessageLink.externalMessageId, + }) + } + } - return { - status: "deleted" as const, - externalMessageId: normalizedMessageLink.externalMessageId, - } - }) + yield* messageLinkRepo.softDelete(messageLink.id) + yield* writeReceipt({ + syncConnectionId, + channelLinkId: link.id, + source: "hazel", + dedupeKey, + payload: { + hazelMessageId, + externalMessageId: normalizedMessageLink.externalMessageId, + }, + }) + yield* connectionRepo.updateLastSyncedAt(syncConnectionId) + yield* channelLinkRepo.updateLastSyncedAt(link.id) + + return { + status: "deleted" as const, + externalMessageId: normalizedMessageLink.externalMessageId, + } + }, + ( + effect, + syncConnectionId: SyncConnectionId, + hazelMessageId: MessageId, + dedupeKeyOverride?: string, + ) => + releaseClaimOnRetryableFailure( + syncConnectionId, + hazelDedupeKey.messageDelete(hazelMessageId, dedupeKeyOverride), + )(effect), + ) const syncHazelReactionCreateToProvider = Effect.fn( "discordSyncWorker.syncHazelReactionCreateToProvider", - )(function* ( - syncConnectionId: SyncConnectionId, - hazelReactionId: MessageReactionId, - dedupeKeyOverride?: string, - ) { - const dedupeKey = dedupeKeyOverride ?? `hazel:reaction:create:${hazelReactionId}` - const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) - if (!claimed) { - return { status: "deduped" as const } - } + )( + function* ( + syncConnectionId: SyncConnectionId, + hazelReactionId: MessageReactionId, + dedupeKeyOverride?: string, + ) { + const dedupeKey = hazelDedupeKey.reactionCreate(hazelReactionId, dedupeKeyOverride) + const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) + if (!claimed) { + return { status: "deduped" as const } + } - const connectionOption = yield* connectionRepo.findById(syncConnectionId) - if (Option.isNone(connectionOption)) { - return yield* Effect.fail( - new DiscordSyncConnectionNotFoundError({ + const connectionOption = yield* connectionRepo.findById(syncConnectionId) + if (Option.isNone(connectionOption)) { + return yield* Effect.fail( + new DiscordSyncConnectionNotFoundError({ + syncConnectionId, + }), + ) + } + const connection = connectionOption.value + const adapter = yield* getProviderAdapter(connection.provider) + + const reactionOption = yield* messageReactionRepo.findById(hazelReactionId) + if (Option.isNone(reactionOption)) { + yield* writeReceipt({ syncConnectionId, - }), - ) - } - const connection = connectionOption.value - const adapter = yield* getProviderAdapter(connection.provider) + source: "hazel", + dedupeKey, + status: "ignored", + }) + return { status: "ignored_missing_reaction" as const } + } + const reaction = reactionOption.value - const reactionOption = yield* messageReactionRepo.findById(hazelReactionId) - if (Option.isNone(reactionOption)) { - yield* writeReceipt({ + const link = yield* resolveOrCreateOutboundLinkForMessage({ syncConnectionId, - source: "hazel", - dedupeKey, - status: "ignored", + provider: connection.provider, + hazelChannelId: reaction.channelId, }) - return { status: "ignored_missing_reaction" as const } - } - const reaction = reactionOption.value + const normalizedLink = normalizeChannelLinkExternalId(link) - const link = yield* resolveOrCreateOutboundLinkForMessage({ - syncConnectionId, - provider: connection.provider, - hazelChannelId: reaction.channelId, - }) - const normalizedLink = normalizeChannelLinkExternalId(link) + const messageLinkOption = yield* messageLinkRepo.findByHazelMessage( + link.id, + reaction.messageId, + ) + + if (Option.isNone(messageLinkOption)) { + yield* writeReceipt({ + syncConnectionId, + channelLinkId: link.id, + source: "hazel", + dedupeKey, + status: "ignored", + payload: { + hazelReactionId, + }, + }) + return { status: "ignored_missing_link" as const } + } + const messageLink = messageLinkOption.value + const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) - const messageLinkOption = yield* messageLinkRepo.findByHazelMessage(link.id, reaction.messageId) + yield* adapter.addReaction({ + externalChannelId: normalizedLink.externalChannelId, + externalMessageId: normalizedMessageLink.externalMessageId, + emoji: reaction.emoji, + }) - if (Option.isNone(messageLinkOption)) { yield* writeReceipt({ syncConnectionId, channelLinkId: link.id, source: "hazel", dedupeKey, - status: "ignored", payload: { hazelReactionId, + externalMessageId: normalizedMessageLink.externalMessageId, + emoji: reaction.emoji, }, }) - return { status: "ignored_missing_link" as const } - } - const messageLink = messageLinkOption.value - const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) + yield* connectionRepo.updateLastSyncedAt(syncConnectionId) + yield* channelLinkRepo.updateLastSyncedAt(link.id) - yield* adapter.addReaction({ - externalChannelId: normalizedLink.externalChannelId, - externalMessageId: normalizedMessageLink.externalMessageId, - emoji: reaction.emoji, - }) + return { + status: "created" as const, + externalMessageId: normalizedMessageLink.externalMessageId, + } + }, + ( + effect, + syncConnectionId: SyncConnectionId, + hazelReactionId: MessageReactionId, + dedupeKeyOverride?: string, + ) => + releaseClaimOnRetryableFailure( + syncConnectionId, + hazelDedupeKey.reactionCreate(hazelReactionId, dedupeKeyOverride), + )(effect), + ) - yield* writeReceipt({ - syncConnectionId, - channelLinkId: link.id, - source: "hazel", - dedupeKey, + const syncHazelReactionDeleteToProvider = Effect.fn( + "discordSyncWorker.syncHazelReactionDeleteToProvider", + )( + function* ( + syncConnectionId: SyncConnectionId, payload: { - hazelReactionId, - externalMessageId: normalizedMessageLink.externalMessageId, - emoji: reaction.emoji, + hazelChannelId: ChannelId + hazelMessageId: MessageId + emoji: string + userId?: UserId }, - }) - yield* connectionRepo.updateLastSyncedAt(syncConnectionId) - yield* channelLinkRepo.updateLastSyncedAt(link.id) + dedupeKeyOverride?: string, + ) { + const dedupeKey = hazelDedupeKey.reactionDelete(payload, dedupeKeyOverride) + const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) + if (!claimed) { + return { status: "deduped" as const } + } - return { - status: "created" as const, - externalMessageId: normalizedMessageLink.externalMessageId, - } - }) + const connectionOption = yield* connectionRepo.findById(syncConnectionId) + if (Option.isNone(connectionOption)) { + return yield* Effect.fail( + new DiscordSyncConnectionNotFoundError({ + syncConnectionId, + }), + ) + } + const connection = connectionOption.value + const adapter = yield* getProviderAdapter(connection.provider) - const syncHazelReactionDeleteToProvider = Effect.fn( - "discordSyncWorker.syncHazelReactionDeleteToProvider", - )(function* ( - syncConnectionId: SyncConnectionId, - payload: { - hazelChannelId: ChannelId - hazelMessageId: MessageId - emoji: string - userId?: UserId - }, - dedupeKeyOverride?: string, - ) { - const dedupeKey = - dedupeKeyOverride ?? - `hazel:reaction:delete:${payload.hazelMessageId}:${payload.emoji}:${payload.userId ?? "unknown"}` - const claimed = yield* claimReceipt({ syncConnectionId, source: "hazel", dedupeKey }) - if (!claimed) { - return { status: "deduped" as const } - } + const link = yield* resolveOrCreateOutboundLinkForMessage({ + syncConnectionId, + provider: connection.provider, + hazelChannelId: payload.hazelChannelId, + }) + const normalizedLink = normalizeChannelLinkExternalId(link) - const connectionOption = yield* connectionRepo.findById(syncConnectionId) - if (Option.isNone(connectionOption)) { - return yield* Effect.fail( - new DiscordSyncConnectionNotFoundError({ - syncConnectionId, - }), + const messageLinkOption = yield* messageLinkRepo.findByHazelMessage( + link.id, + payload.hazelMessageId, ) - } - const connection = connectionOption.value - const adapter = yield* getProviderAdapter(connection.provider) - const link = yield* resolveOrCreateOutboundLinkForMessage({ - syncConnectionId, - provider: connection.provider, - hazelChannelId: payload.hazelChannelId, - }) - const normalizedLink = normalizeChannelLinkExternalId(link) + if (Option.isNone(messageLinkOption)) { + yield* writeReceipt({ + syncConnectionId, + channelLinkId: link.id, + source: "hazel", + dedupeKey, + status: "ignored", + payload, + }) + return { status: "ignored_missing_link" as const } + } + const messageLink = messageLinkOption.value + const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) - const messageLinkOption = yield* messageLinkRepo.findByHazelMessage( - link.id, - payload.hazelMessageId, - ) + const remainingReactions = yield* db.run( + PG.from(schema.messageReactionsTable) + .select("id") + .where(($) => [$.messageId.eq(payload.hazelMessageId), $.emoji.eq(payload.emoji)]) + .limit(1), + ) + if (remainingReactions.length > 0) { + yield* writeReceipt({ + syncConnectionId, + channelLinkId: link.id, + source: "hazel", + dedupeKey, + status: "ignored", + payload: { + ...payload, + reason: "remaining_reactions_for_emoji", + }, + }) + return { status: "ignored_remaining_reactions" as const } + } - if (Option.isNone(messageLinkOption)) { - yield* writeReceipt({ - syncConnectionId, - channelLinkId: link.id, - source: "hazel", - dedupeKey, - status: "ignored", - payload, + yield* adapter.removeReaction({ + externalChannelId: normalizedLink.externalChannelId, + externalMessageId: normalizedMessageLink.externalMessageId, + emoji: payload.emoji, }) - return { status: "ignored_missing_link" as const } - } - const messageLink = messageLinkOption.value - const normalizedMessageLink = normalizeMessageLinkExternalId(messageLink) - const remainingReactions = yield* db.run( - PG.from(schema.messageReactionsTable) - .select("id") - .where(($) => [$.messageId.eq(payload.hazelMessageId), $.emoji.eq(payload.emoji)]) - .limit(1), - ) - if (remainingReactions.length > 0) { yield* writeReceipt({ syncConnectionId, channelLinkId: link.id, source: "hazel", dedupeKey, - status: "ignored", payload: { ...payload, - reason: "remaining_reactions_for_emoji", + externalMessageId: normalizedMessageLink.externalMessageId, }, }) - return { status: "ignored_remaining_reactions" as const } - } - - yield* adapter.removeReaction({ - externalChannelId: normalizedLink.externalChannelId, - externalMessageId: normalizedMessageLink.externalMessageId, - emoji: payload.emoji, - }) + yield* connectionRepo.updateLastSyncedAt(syncConnectionId) + yield* channelLinkRepo.updateLastSyncedAt(link.id) - yield* writeReceipt({ - syncConnectionId, - channelLinkId: link.id, - source: "hazel", - dedupeKey, - payload: { - ...payload, + return { + status: "deleted" as const, externalMessageId: normalizedMessageLink.externalMessageId, - }, - }) - yield* connectionRepo.updateLastSyncedAt(syncConnectionId) - yield* channelLinkRepo.updateLastSyncedAt(link.id) - - return { - status: "deleted" as const, - externalMessageId: normalizedMessageLink.externalMessageId, - } - }) + } + }, + ( + effect, + syncConnectionId: SyncConnectionId, + payload: { hazelMessageId: MessageId; emoji: string; userId?: UserId }, + dedupeKeyOverride?: string, + ) => + releaseClaimOnRetryableFailure( + syncConnectionId, + hazelDedupeKey.reactionDelete(payload, dedupeKeyOverride), + )(effect), + ) const getActiveOutboundTargets = Effect.fn("discordSyncWorker.getActiveOutboundTargets")(function* ( hazelChannelId: ChannelId, @@ -1698,6 +1806,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const targets = yield* getActiveOutboundTargets(messageOption.value.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1712,6 +1821,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } else { failed++ + if (isRetryableSyncFailure(result.failure)) retryable.push(result.failure) yield* Effect.logWarning("Failed to sync create message to provider", { provider, hazelMessageId, @@ -1721,6 +1831,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1734,6 +1853,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const targets = yield* getActiveOutboundTargets(messageOption.value.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1748,6 +1868,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } else { failed++ + if (isRetryableSyncFailure(result.failure)) retryable.push(result.failure) yield* Effect.logWarning("Failed to sync update message to provider", { provider, hazelMessageId, @@ -1757,6 +1878,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1770,6 +1900,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const targets = yield* getActiveOutboundTargets(messageOption.value.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1784,6 +1915,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } else { failed++ + if (isRetryableSyncFailure(result.failure)) retryable.push(result.failure) yield* Effect.logWarning("Failed to sync delete message to provider", { provider, hazelMessageId, @@ -1793,6 +1925,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1807,6 +1948,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const targets = yield* getActiveOutboundTargets(reaction.channelId, provider) let synced = 0 let failed = 0 + const retryable: Array = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1821,6 +1963,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } else { failed++ + if (isRetryableSyncFailure(result.failure)) retryable.push(result.failure) yield* Effect.logWarning("Failed to sync create reaction to provider", { provider, hazelReactionId, @@ -1830,6 +1973,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) @@ -1848,6 +2000,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk const targets = yield* getActiveOutboundTargets(payload.hazelChannelId, provider) let synced = 0 let failed = 0 + const retryable: Array = [] for (const target of targets) { if (target.direction === "external_to_hazel") continue @@ -1862,6 +2015,7 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } else { failed++ + if (isRetryableSyncFailure(result.failure)) retryable.push(result.failure) yield* Effect.logWarning("Failed to sync delete reaction to provider", { provider, hazelMessageId: payload.hazelMessageId, @@ -1871,6 +2025,15 @@ export const ChatSyncCoreWorkerMake: Effect.Effect, unk } } + if (retryable.length > 0) { + return yield* Effect.fail( + new ChatSyncDeliveryRetryableError({ + provider, + failed: retryable.length, + cause: retryable[0], + }), + ) + } return { synced, failed } }) diff --git a/apps/backend/src/services/chat-sync/discord-gateway-service.ts b/apps/backend/src/services/chat-sync/discord-gateway-service.ts index 8c21a8292..1448cdb39 100644 --- a/apps/backend/src/services/chat-sync/discord-gateway-service.ts +++ b/apps/backend/src/services/chat-sync/discord-gateway-service.ts @@ -11,7 +11,7 @@ import { } from "@hazel/schema" import { DiscordConfig } from "dfx" import { DiscordGateway, DiscordLive } from "dfx/gateway" -import { Context, Config, Effect, Layer, Option, Redacted, Ref, Schema } from "effect" +import { Cause, Context, Config, Effect, Layer, Option, Redacted, Ref, Schema } from "effect" import type * as Socket from "effect/socket/Socket" import { DiscordSyncWorker, DiscordSyncWorkerLayer } from "./discord-sync-worker" import type { ChatSyncIngressMessageAttachment } from "./chat-sync-core-worker" @@ -726,9 +726,9 @@ export class DiscordGatewayService extends Context.Service - Effect.logError("Discord gateway background worker stopped", { - cause: String(cause), - }), + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logError("Discord gateway background worker stopped", cause), ), ) }) as Effect.Effect diff --git a/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts b/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts index c98ab88c3..4bbe29571 100644 --- a/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts +++ b/apps/backend/src/services/chat-sync/discord-sync-worker.test.ts @@ -2812,7 +2812,12 @@ describe("DiscordSyncWorker outbound webhook dispatch", () => { }, createWebhook: () => { webhookProvisioned = true - return Effect.fail(new Error("webhook provisioning failed")) + return Effect.fail( + new Discord.DiscordApiError({ + message: "webhook provisioning failed", + status: 500, + }), + ) }, executeWebhookMessage: () => Effect.fail(new Error("should not be called")), updateWebhookMessage: () => Effect.succeed(undefined), @@ -3162,7 +3167,8 @@ describe("DiscordSyncWorker outbound attachments primitive", () => { }), } as unknown as ChatSyncProviderRegistry, discordApiClient: { - executeWebhookMessage: () => Effect.fail(new Error("webhook send failed")), + executeWebhookMessage: () => + Effect.fail(new Discord.DiscordApiError({ message: "webhook send failed", status: 500 })), createWebhook: () => Effect.fail(new Error("not expected")), updateWebhookMessage: () => Effect.succeed(undefined), deleteWebhookMessage: () => Effect.succeed(undefined), diff --git a/apps/backend/src/services/connect-conversation-service.ts b/apps/backend/src/services/connect-conversation-service.ts index 1e7c8cda7..99ef114c9 100644 --- a/apps/backend/src/services/connect-conversation-service.ts +++ b/apps/backend/src/services/connect-conversation-service.ts @@ -8,6 +8,7 @@ import { } from "@hazel/backend-core" import { Database, type DatabaseError } from "@hazel/db" import { InternalServerError } from "@hazel/domain" +import { ConnectConversationNotFoundError } from "@hazel/domain/rpc" import type { ChannelId, ConnectConversationId, OrganizationId, UserId } from "@hazel/schema" import { Context, Effect, Layer, Option } from "effect" import { ChannelAccessSyncService } from "./channel-access-sync" @@ -244,9 +245,9 @@ export class ConnectConversationService extends Context.Service({ - authenticateWithBearer: () => Effect.die(new Error("Clerk should not be consulted")), - }), -) - const successValue = { _: Symbol("success") } as SuccessValue const tagOf = (error: unknown) => @@ -158,7 +148,6 @@ describe("Hazel sign-in in the backend", () => { Layer.provideMerge(HazelSession.layer), Layer.provideMerge(HazelAuthServicesLive), Layer.provideMerge(HazelAuthInstance.layer), - Layer.provide(NoClerk), ) web = HttpRouter.toWebHandler( diff --git a/apps/backend/src/services/hazel-auth.ts b/apps/backend/src/services/hazel-auth.ts index 639b42d58..610d66618 100644 --- a/apps/backend/src/services/hazel-auth.ts +++ b/apps/backend/src/services/hazel-auth.ts @@ -10,9 +10,9 @@ import { Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect import { Headers, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" /** - * Hazel's own sign-in (`@hazel/auth/server`), replacing Clerk. Enabled when - * `AUTH_GITHUB_CLIENT_ID` is set; until then Clerk remains the only sign-in and these - * routes are not mounted. + * Hazel's own sign-in (`@hazel/auth/server`). Enabled when `AUTH_GITHUB_CLIENT_ID` is set; + * until then nobody can sign in (only bot tokens authenticate) and these routes are not + * mounted. * * - `/auth/*`: sign-in, registration, passkeys and sessions (cookie on the API host). * - `POST /auth/token`: exchanges the session for a short-lived JWT for the hosts that diff --git a/apps/backend/src/services/hazel-session.ts b/apps/backend/src/services/hazel-session.ts index deb3b5677..d8c5b736a 100644 --- a/apps/backend/src/services/hazel-session.ts +++ b/apps/backend/src/services/hazel-session.ts @@ -1,8 +1,8 @@ import { HazelAuth } from "@hazel/auth/server" import { UserRepo } from "@hazel/backend-core" import { CurrentUser, InvalidBearerTokenError, SessionLoadError } from "@hazel/domain" -import type { UserId } from "@hazel/schema" -import { Context, Effect, Layer, Option, Redacted } from "effect" +import { UserId } from "@hazel/schema" +import { Cause, Context, Effect, Layer, Option, Redacted } from "effect" import { Cookies, Headers } from "effect/http" import { HazelAuthInstance } from "./hazel-auth" @@ -29,11 +29,18 @@ export class HazelSession extends Context.Service()("@hazel/backen new InvalidBearerTokenError({ message: "Invalid session", detail }) const loadUser = (subjectId: string) => - userRepo.findById(subjectId as UserId).pipe( + userRepo.findById(UserId.make(subjectId)).pipe( Effect.catchTags({ DatabaseError: (error) => - Effect.fail( - new SessionLoadError({ message: "Failed to load user", detail: String(error) }), + Effect.logError("Failed to load the session user", Cause.fail(error)).pipe( + Effect.andThen( + Effect.fail( + new SessionLoadError({ + message: "Failed to load user", + detail: "The session store is unavailable", + }), + ), + ), ), }), Effect.flatMap( diff --git a/apps/backend/src/services/invitations.test.ts b/apps/backend/src/services/invitations.test.ts index 652135594..30461b6b9 100644 --- a/apps/backend/src/services/invitations.test.ts +++ b/apps/backend/src/services/invitations.test.ts @@ -228,4 +228,30 @@ describe("invitations", () => { expect(Option.isNone(result.accepted)).toBe(true) expect(Option.getOrNull(result.pending)?.status).toBe("pending") }) + + it("inviting into a deleted organization fails with OrganizationNotFoundError, not a 500", async () => { + const deletedOrgId = Schema.decodeSync(OrganizationId)(id(2)) + await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + yield* db.execute( + Database.sql.raw( + `INSERT INTO organizations (id, name, slug, "isPublic", "createdAt", "updatedAt", "deletedAt") + VALUES ('${deletedOrgId}', 'Gone Labs', 'gone-labs', false, NOW(), NOW(), NOW())`, + ), + ) + yield* db.execute( + Database.sql.raw( + `INSERT INTO organization_members (id, "organizationId", "userId", role, "joinedAt", "createdAt") + VALUES ('${id(22)}', '${deletedOrgId}', '${owner}', 'owner', NOW(), NOW())`, + ), + ) + }), + ) + + const exit = await run(owner, "organization-members:write", (invitations) => + invitations.create({ organizationId: deletedOrgId, emails: ["late@hazel.test"], role: "member" }), + ) + expect(failureTag(exit)).toEqual(["OrganizationNotFoundError"]) + }) }) diff --git a/apps/backend/src/services/invitations.ts b/apps/backend/src/services/invitations.ts index 36af8baed..0bd31c342 100644 --- a/apps/backend/src/services/invitations.ts +++ b/apps/backend/src/services/invitations.ts @@ -6,7 +6,7 @@ import { OrganizationRepo, } from "@hazel/backend-core" import { Database, type PG, type schema } from "@hazel/db" -import { CurrentUser, InternalServerError, withRemapDbErrors } from "@hazel/domain" +import { CurrentUser, withRemapDbErrors } from "@hazel/domain" import { Invitation, InvitationAccepted, @@ -111,11 +111,7 @@ export class Invitations extends Context.Service()("Invitations", { Effect.gen(function* () { yield* policy.canManage(organizationId, "create") const currentUser = yield* CurrentUser.Context - const organization = yield* findOrganization(organizationId).pipe( - Effect.catchTag("OrganizationNotFoundError", () => - Effect.fail(new InternalServerError({ message: "Organization not found" })), - ), - ) + const organization = yield* findOrganization(organizationId) const inviterName = `${currentUser.firstName} ${currentUser.lastName}`.trim() || currentUser.email diff --git a/apps/backend/src/services/message-outbox-dispatcher.ts b/apps/backend/src/services/message-outbox-dispatcher.ts index 0afe4eae2..6dd88d57b 100644 --- a/apps/backend/src/services/message-outbox-dispatcher.ts +++ b/apps/backend/src/services/message-outbox-dispatcher.ts @@ -1,6 +1,6 @@ import { Pool, type PoolClient } from "pg" import { Database } from "@hazel/db" -import { Context, Effect, Layer, Redacted } from "effect" +import { Cause, Context, Effect, Layer, Redacted, Schema } from "effect" import { EnvVars } from "../lib/env-vars" import { MessageOutboxProcessor } from "./message-outbox-processor" @@ -9,6 +9,12 @@ const OUTBOX_POLL_MAX_MS = 2_000 const OUTBOX_LOCK_RETRY_INTERVAL = "5 seconds" const OUTBOX_DISPATCHER_LOCK_KEY = 1_046_277_921 +/** Reserving the advisory-lock connection or taking the lock failed; the campaign retries. */ +class OutboxLockError extends Schema.TaggedError()("OutboxLockError", { + step: Schema.Literals(["connect", "lock"]), + cause: Schema.Defect(), +}) {} + export class MessageOutboxDispatcher extends Context.Service()( "MessageOutboxDispatcher", { @@ -32,14 +38,17 @@ export class MessageOutboxDispatcher extends Context.Service Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => - reserved.query<{ unlocked: boolean }>( - "SELECT pg_advisory_unlock($1) AS unlocked", - [OUTBOX_DISPATCHER_LOCK_KEY], - ), - catch: () => null, - }).pipe(Effect.ignore) + // Best-effort: the lock is released with the session anyway when the connection closes. + yield* Effect.tryPromise(() => + reserved.query<{ unlocked: boolean }>("SELECT pg_advisory_unlock($1) AS unlocked", [ + OUTBOX_DISPATCHER_LOCK_KEY, + ]), + ).pipe( + Effect.ignore({ + log: "Debug", + message: "Failed to release the outbox advisory lock", + }), + ) yield* Effect.sync(() => reserved.release()) }) @@ -61,9 +70,8 @@ export class MessageOutboxDispatcher extends Context.Service Effect.gen(function* () { - yield* Effect.logError("Message outbox batch failed", { + yield* Effect.logError("Message outbox batch failed", Cause.fail(error), { workerId, - error: String(error), }) yield* Effect.sleep("1 second") pollDelayMs = OUTBOX_POLL_MIN_MS @@ -73,17 +81,20 @@ export class MessageOutboxDispatcher extends Context.Service => Effect.gen(function* () { const reservedResult = yield* Effect.tryPromise({ try: (): Promise => pool.connect(), - catch: (cause) => new Error(String(cause)), + catch: (cause) => new OutboxLockError({ step: "connect", cause }), }).pipe(Effect.result) if (reservedResult._tag === "Failure") { - yield* Effect.logError("Failed to reserve outbox advisory lock connection", { - error: String(reservedResult.failure), - }) + yield* Effect.logError( + "Failed to reserve outbox advisory lock connection", + Cause.fail(reservedResult.failure), + ) yield* Effect.sleep(OUTBOX_LOCK_RETRY_INTERVAL) return yield* campaignForLeadership() } @@ -94,20 +105,20 @@ export class MessageOutboxDispatcher extends Context.Service("SELECT pg_try_advisory_lock($1) AS locked", [ OUTBOX_DISPATCHER_LOCK_KEY, ]), - catch: (cause) => new Error(String(cause)), + catch: (cause) => new OutboxLockError({ step: "lock", cause }), }).pipe(Effect.result) if (lockResult._tag === "Failure") { - yield* Effect.logError("Failed to acquire outbox advisory lock", { - error: String(lockResult.failure), - }) + yield* Effect.logError( + "Failed to acquire outbox advisory lock", + Cause.fail(lockResult.failure), + ) yield* Effect.sync(() => reserved.release()) yield* Effect.sleep(OUTBOX_LOCK_RETRY_INTERVAL) return yield* campaignForLeadership() } - const lockRows = lockResult.success as { rows: Array<{ locked: boolean }> } - if (!lockRows.rows[0]?.locked) { + if (!lockResult.success.rows[0]?.locked) { yield* Effect.sync(() => reserved.release()) yield* Effect.sleep(OUTBOX_LOCK_RETRY_INTERVAL) return yield* campaignForLeadership() @@ -119,11 +130,13 @@ export class MessageOutboxDispatcher extends Context.Service - Effect.logError("Message outbox dispatcher leader loop stopped", { - workerId, - cause: String(cause), - }), + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logError("Message outbox dispatcher leader loop stopped", cause, { + workerId, + }), ), ) diff --git a/apps/backend/src/services/message-outbox-processor.ts b/apps/backend/src/services/message-outbox-processor.ts index 77c6a6b5d..283a01c30 100644 --- a/apps/backend/src/services/message-outbox-processor.ts +++ b/apps/backend/src/services/message-outbox-processor.ts @@ -45,31 +45,31 @@ export class MessageOutboxProcessor extends Context.Service( @@ -62,10 +63,12 @@ const makeDiscordWorker = (calls: DiscordCall[], options: WorkerOptions = {}) => return { synced: 1, failed: 0 } }), syncHazelMessageUpdateToAllConnections: (messageId: string, dedupeKey?: string) => - Effect.sync(() => { - calls.push({ method: "message_update", id: messageId, dedupeKey }) - return { synced: 1, failed: 0 } - }), + options.failUpdate + ? Effect.fail(new Error("discord update failed")) + : Effect.sync(() => { + calls.push({ method: "message_update", id: messageId, dedupeKey }) + return { synced: 1, failed: 0 } + }), syncHazelMessageDeleteToAllConnections: (messageId: string, dedupeKey?: string) => Effect.sync(() => { calls.push({ method: "message_delete", id: messageId, dedupeKey }) @@ -290,10 +293,10 @@ describe("MessageSideEffectService", () => { expect(requests[0]?.url).toContain("messagenotificationworkflow") }) - it("continues to workflows when Discord create sync fails", async () => { + it("still notifies when Discord create sync fails, then fails so the outbox retries", async () => { const requests: Array<{ url: string }> = [] - await runServiceEffect( + const result = await runServiceEffect( harness, makeDiscordWorker([], { failCreate: true }), Effect.gen(function* () { @@ -308,12 +311,26 @@ describe("MessageSideEffectService", () => { }, "dedupe-3", ) - }), + }).pipe(Effect.result), workflowClientLayer(requests), ) expect(requests).toHaveLength(1) expect(requests[0]?.url).toContain("messagenotificationworkflow") + expect(Result.isFailure(result)).toBe(true) + }) + + it("fails so the outbox retries when Discord update sync fails", async () => { + const result = await runServiceEffect( + harness, + makeDiscordWorker([], { failUpdate: true }), + Effect.gen(function* () { + const service = yield* MessageSideEffectService + yield* service.handleMessageUpdated({ messageId: NEW_THREAD_MESSAGE_ID }, "dedupe-4") + }).pipe(Effect.result), + ) + + expect(Result.isFailure(result)).toBe(true) }) it("routes message updates, deletes, and reactions to the correct Discord worker methods", async () => { diff --git a/apps/backend/src/services/message-side-effect-service.ts b/apps/backend/src/services/message-side-effect-service.ts index 5f348f06c..dce5d43f8 100644 --- a/apps/backend/src/services/message-side-effect-service.ts +++ b/apps/backend/src/services/message-side-effect-service.ts @@ -1,6 +1,6 @@ import { Database, PG, schema } from "@hazel/db" -import { WorkflowInitializationError } from "@hazel/domain" -import { Context, Array, Config, Effect, Layer, Option } from "effect" +import { WorkflowInitializationError, WorkflowServiceUnavailableError } from "@hazel/domain" +import { Array, Cause, Config, Context, Effect, Exit, Layer, Option } from "effect" import type { MessageCreatedPayload, MessageDeletedPayload, @@ -8,10 +8,15 @@ import type { ReactionCreatedPayload, ReactionDeletedPayload, } from "@hazel/backend-core" -import { formatError } from "../lib/format-error" import { DiscordSyncWorker, DiscordSyncWorkerLayer } from "./chat-sync/discord-sync-worker" import { makeClusterClient } from "../lib/cluster-client" +/** + * The outbox handlers for message and reaction events. A handler that fails makes the outbox + * retry the event with backoff, so every failure that may succeed later (database blips, the + * cluster or Discord being unreachable) propagates. Retries are safe: Discord deliveries are + * deduped by their receipts and the workflows by their idempotency keys. + */ export class MessageSideEffectService extends Context.Service()( "MessageSideEffectService", { @@ -21,17 +26,16 @@ export class MessageSideEffectService extends Context.Service [$.externalId.eq("integration-bot-discord"), $.deletedAt.isNull()]) - .limit(1), - ) - .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed(Option.none()))) + const integrationBot = yield* db.first( + PG.from(schema.usersTable) + .select("id") + .where(($) => [$.externalId.eq("integration-bot-discord"), $.deletedAt.isNull()]) + .limit(1), + ) return Option.getOrNull(Option.map(integrationBot, (row) => row.id)) }) @@ -39,42 +43,20 @@ export class MessageSideEffectService extends Context.Service - Effect.logWarning("Failed to sync outbox message create to Discord", { - messageId: payload.messageId, - channelId: payload.channelId, - error: formatError(error), - }), - ), - ) - } else { - yield* Effect.logDebug("Skipping outbox message create from integration bot", { - messageId: payload.messageId, - channelId: payload.channelId, - }) - } - - const channelResult = yield* db - .run( - PG.from(schema.channelsTable) - .select("type", "name") - .where(($) => [$.id.eq(payload.channelId)]) - .limit(1), - ) - .pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new WorkflowInitializationError({ - message: "Failed to query channel type", - cause: err.message, - }), - ), - ), - ) + // Settled now, failed last: a Discord outage must not hold back notifications. + const discordSync = + payload.authorId === integrationBotUserId + ? Exit.void + : yield* discordSyncWorker + .syncHazelMessageCreateToAllConnections(payload.messageId, dedupeKey) + .pipe(Effect.asVoid, Effect.exit) + + const channelResult = yield* db.run( + PG.from(schema.channelsTable) + .select("type", "name") + .where(($) => [$.id.eq(payload.channelId)]) + .limit(1), + ) const channelType = Array.head(channelResult).pipe( Option.map((row) => row.type), @@ -93,147 +75,141 @@ export class MessageSideEffectService extends Context.Service - Effect.logError("MessageNotificationWorkflow dispatch failed", { - messageId: payload.messageId, - channelId: payload.channelId, - errorTag: - typeof err === "object" && err && "_tag" in err - ? (err as { _tag: string })._tag - : "unknown", - error: formatError(err), - }), - ), - Effect.catch((err) => - Effect.fail( - new WorkflowInitializationError({ - message: "Failed to execute notification workflow", - cause: formatError(err), - }), - ), - ), + Effect.catchTags({ + HttpClientError: (cause) => + Effect.fail( + new WorkflowServiceUnavailableError({ + message: "Cluster unreachable for the notification workflow", + cause, + }), + ), + // The workflow ran and failed; its result is stored under the message id, + // so a retry would get the same failure back. + GetChannelMembersError: (cause) => + Effect.logError("MessageNotificationWorkflow failed", Cause.fail(cause)), + CreateNotificationError: (cause) => + Effect.logError("MessageNotificationWorkflow failed", Cause.fail(cause)), + SchemaError: (cause) => + Effect.fail( + new WorkflowInitializationError({ + message: "Failed to execute notification workflow", + cause, + }), + ), + }), ) - if (channelType !== "thread") { - return - } - - const messageCountResult = yield* db - .run( - PG.from(schema.messagesTable) - .select(() => ({ count: PG.count() })) - .where(($) => [$.channelId.eq(payload.channelId), $.deletedAt.isNull()]), - ) - .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed([{ count: 0 }]))) - - const count = Array.head(messageCountResult).pipe( - Option.map((r) => r.count), - Option.getOrElse(() => 0), - ) - const channelName = Array.head(channelResult).pipe( - Option.map((r) => r.name), - Option.getOrNull, - ) - if (count <= 3 || channelName !== "Thread") { - return - } - - const originalMessage = yield* db - .first( - PG.from(schema.messagesTable) - .select("id") - .where(($) => [$.threadChannelId.eq(payload.channelId)]) - .limit(1), - ) - .pipe(Effect.catchTag("DatabaseError", () => Effect.succeed(Option.none()))) - if (Option.isNone(originalMessage)) { - return + if (channelType === "thread") { + yield* nameThreadIfDue(payload, Array.head(channelResult)) } - yield* client.workflows - .ThreadNamingWorkflow({ - payload: { - threadChannelId: payload.channelId, - originalMessageId: originalMessage.value.id, - }, - }) - .pipe( - Effect.tapError((err) => - Effect.logError("Failed to execute thread naming workflow", { - threadChannelId: payload.channelId, - error: formatError(err), - }), - ), - Effect.catch(() => Effect.void), - ) + yield* discordSync }, ) + const nameThreadIfDue = Effect.fn("MessageSideEffectService.nameThreadIfDue")(function* ( + payload: MessageCreatedPayload, + channel: Option.Option<{ name: string }>, + ) { + const messageCountResult = yield* db.run( + PG.from(schema.messagesTable) + .select(() => ({ count: PG.count() })) + .where(($) => [$.channelId.eq(payload.channelId), $.deletedAt.isNull()]), + ) + + const count = Array.head(messageCountResult).pipe( + Option.map((r) => r.count), + Option.getOrElse(() => 0), + ) + const channelName = Option.getOrNull(Option.map(channel, (row) => row.name)) + if (count <= 3 || channelName !== "Thread") { + return + } + + const originalMessage = yield* db.first( + PG.from(schema.messagesTable) + .select("id") + .where(($) => [$.threadChannelId.eq(payload.channelId)]) + .limit(1), + ) + if (Option.isNone(originalMessage)) { + return + } + + // Naming is best-effort: a failed run is stored under the thread's idempotency key, so + // only an unreachable cluster is worth a retry. + const logNamingFailure = (cause: unknown) => + Effect.logWarning("ThreadNamingWorkflow failed", { + threadChannelId: payload.channelId, + cause: Cause.pretty(Cause.fail(cause)), + }) + + yield* client.workflows + .ThreadNamingWorkflow({ + payload: { + threadChannelId: payload.channelId, + originalMessageId: originalMessage.value.id, + }, + }) + .pipe( + Effect.catchTags({ + HttpClientError: (cause) => + Effect.fail( + new WorkflowServiceUnavailableError({ + message: "Cluster unreachable for the thread naming workflow", + cause, + }), + ), + ThreadChannelNotFoundError: logNamingFailure, + OriginalMessageNotFoundError: logNamingFailure, + ThreadContextQueryError: logNamingFailure, + AIProviderUnavailableError: logNamingFailure, + AIRateLimitError: logNamingFailure, + AIResponseParseError: logNamingFailure, + ThreadNameUpdateError: logNamingFailure, + SchemaError: logNamingFailure, + }), + ) + }) + const handleMessageUpdated = Effect.fn("MessageSideEffectService.handleMessageUpdated")( function* (payload: MessageUpdatedPayload, dedupeKey: string) { - yield* discordSyncWorker - .syncHazelMessageUpdateToAllConnections(payload.messageId, dedupeKey) - .pipe( - Effect.catch((error) => - Effect.logWarning("Failed to sync outbox message update to Discord", { - messageId: payload.messageId, - error: formatError(error), - }), - ), - ) + yield* discordSyncWorker.syncHazelMessageUpdateToAllConnections( + payload.messageId, + dedupeKey, + ) }, ) const handleMessageDeleted = Effect.fn("MessageSideEffectService.handleMessageDeleted")( function* (payload: MessageDeletedPayload, dedupeKey: string) { - yield* discordSyncWorker - .syncHazelMessageDeleteToAllConnections(payload.messageId, dedupeKey) - .pipe( - Effect.catch((error) => - Effect.logWarning("Failed to sync outbox message delete to Discord", { - messageId: payload.messageId, - error: formatError(error), - }), - ), - ) + yield* discordSyncWorker.syncHazelMessageDeleteToAllConnections( + payload.messageId, + dedupeKey, + ) }, ) const handleReactionCreated = Effect.fn("MessageSideEffectService.handleReactionCreated")( function* (payload: ReactionCreatedPayload, dedupeKey: string) { - yield* discordSyncWorker - .syncHazelReactionCreateToAllConnections(payload.reactionId, dedupeKey) - .pipe( - Effect.catch((error) => - Effect.logWarning("Failed to sync outbox reaction create to Discord", { - reactionId: payload.reactionId, - error: formatError(error), - }), - ), - ) + yield* discordSyncWorker.syncHazelReactionCreateToAllConnections( + payload.reactionId, + dedupeKey, + ) }, ) const handleReactionDeleted = Effect.fn("MessageSideEffectService.handleReactionDeleted")( function* (payload: ReactionDeletedPayload, dedupeKey: string) { - yield* discordSyncWorker - .syncHazelReactionDeleteToAllConnections( - { - hazelChannelId: payload.hazelChannelId, - hazelMessageId: payload.hazelMessageId, - emoji: payload.emoji, - userId: payload.userId, - }, - dedupeKey, - ) - .pipe( - Effect.catch((error) => - Effect.logWarning("Failed to sync outbox reaction delete to Discord", { - hazelMessageId: payload.hazelMessageId, - error: formatError(error), - }), - ), - ) + yield* discordSyncWorker.syncHazelReactionDeleteToAllConnections( + { + hazelChannelId: payload.hazelChannelId, + hazelMessageId: payload.hazelMessageId, + emoji: payload.emoji, + userId: payload.userId, + }, + dedupeKey, + ) }, ) diff --git a/apps/backend/src/services/oauth-bearer-auth.ts b/apps/backend/src/services/oauth-bearer-auth.ts index 52a493b95..7a4e46772 100644 --- a/apps/backend/src/services/oauth-bearer-auth.ts +++ b/apps/backend/src/services/oauth-bearer-auth.ts @@ -1,8 +1,8 @@ -import { ClerkClient } from "@hazel/auth" +import { createClerkClient } from "@clerk/backend" import { UserRepo } from "@hazel/backend-core" -import { CurrentUser, UnauthorizedError } from "@hazel/domain" +import { CurrentUser, SessionLoadError, UnauthorizedError } from "@hazel/domain" import { ClerkUserId } from "@hazel/schema" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Cause, Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect" /** * Authenticates Bearer access tokens issued by Hazel's Clerk OAuth Applications. @@ -12,28 +12,46 @@ import { Context, Effect, Layer, Option, Schema } from "effect" * Verifies the token via Clerk's IdP introspection endpoint, then resolves it * to a local Hazel user via `externalId` (Clerk user ID). * - * This is distinct from `BackendAuth.authenticate`, which verifies *session* - * JWTs minted by Clerk for browser-authenticated users. OAuth Application - * access tokens are a different token class (issued via /oauth/token) and need - * the IdP introspection path. + * NOTE: this is the last Clerk consumer in the backend. Sign-in itself runs on Hazel + * auth; `CLERK_SECRET_KEY` is optional and only read here, so the public API v1 answers + * 503 while it is unset. Drop this (and `@clerk/backend`) once Maple moves to Hazel-issued + * OAuth tokens. */ export class OAuthBearerAuth extends Context.Service()("@hazel/backend/OAuthBearerAuth", { make: Effect.gen(function* () { - const clerk = yield* ClerkClient + const secretKey = yield* Config.option(Config.Redacted("CLERK_SECRET_KEY")) + const clerk = Option.map(secretKey, (key) => createClerkClient({ secretKey: Redacted.value(key) })) const userRepo = yield* UserRepo const decodeClerkUserId = Schema.decodeUnknownEffect(ClerkUserId) const authenticate = (accessToken: string) => Effect.gen(function* () { - const introspected = yield* Effect.tryPromise({ - try: () => clerk.raw.idPOAuthAccessToken.verify(accessToken), - catch: (error) => - new UnauthorizedError({ - message: "Invalid OAuth access token", - detail: String(error), + if (Option.isNone(clerk)) { + return yield* Effect.fail( + new SessionLoadError({ + message: "OAuth token verification is not configured", + detail: "CLERK_SECRET_KEY is not set", }), - }) + ) + } + const client = clerk.value + + const introspected = yield* Effect.tryPromise({ + try: () => client.idPOAuthAccessToken.verify(accessToken), + catch: (error) => error, + }).pipe( + Effect.tapError((error) => + Effect.logWarning("OAuth access token verification failed", Cause.fail(error)), + ), + Effect.mapError( + () => + new UnauthorizedError({ + message: "Invalid OAuth access token", + detail: "The token could not be verified", + }), + ), + ) if (introspected.revoked) { return yield* Effect.fail( @@ -64,12 +82,16 @@ export class OAuthBearerAuth extends Context.Service()("@hazel/ ) const userOption = yield* userRepo.findByExternalId(clerkUserId).pipe( - Effect.catchTag("DatabaseError", (err) => - Effect.fail( - new UnauthorizedError({ - message: "Failed to resolve OAuth user", - detail: String(err), - }), + Effect.catchTag("DatabaseError", (error) => + Effect.logError("Failed to resolve OAuth user", Cause.fail(error)).pipe( + Effect.andThen( + Effect.fail( + new SessionLoadError({ + message: "Failed to resolve OAuth user", + detail: "The user store is unavailable", + }), + ), + ), ), ), ) @@ -109,8 +131,5 @@ export class OAuthBearerAuth extends Context.Service()("@hazel/ } as const }), }) { - static readonly layer = Layer.effect(this, this.make).pipe( - Layer.provide(ClerkClient.layer), - Layer.provide(UserRepo.layer), - ) + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(UserRepo.layer)) } diff --git a/apps/backend/src/services/org-resolver.test.ts b/apps/backend/src/services/org-resolver.test.ts index 6278fdc9f..542571685 100644 --- a/apps/backend/src/services/org-resolver.test.ts +++ b/apps/backend/src/services/org-resolver.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest" import { ChannelMemberRepo, ChannelRepo, MessageRepo, OrganizationMemberRepo } from "@hazel/backend-core" +import { Database } from "@hazel/db" import { PermissionError } from "@hazel/domain" import type { ChannelId, ChannelMemberId, MessageId, OrganizationId, UserId } from "@hazel/schema" import { Effect, Result, Layer, Option, Context } from "effect" @@ -131,6 +132,39 @@ describe("OrgResolver", () => { }) }) + describe("database failures", () => { + it("fail with the typed DatabaseError instead of a defect", async () => { + const layer = Layer.effect(OrgResolver, OrgResolver.make).pipe( + Layer.provide( + Layer.succeed( + OrganizationMemberRepo, + serviceShape({ + findByOrgAndUser: () => + Effect.fail( + new Database.DatabaseError({ + type: "connection_error", + message: "connection refused", + cause: null, + }), + ), + }), + ), + ), + Layer.provide(makeChannelRepoLayer({})), + Layer.provide(makeChannelMemberRepoLayer({})), + Layer.provide(makeMessageRepoLayer({})), + ) + + const result = await runEither( + use((r) => r.requireScope(TEST_ORG_ID, "organizations:read", "Organization", "read")), + layer, + ) + expect(Result.isFailure(result) && (result.failure as { _tag: string })._tag).toBe( + "DatabaseError", + ) + }) + }) + describe("requireAdminOrOwner", () => { it("grants access for admin", async () => { const actor = makeActor() diff --git a/apps/backend/src/services/org-resolver.ts b/apps/backend/src/services/org-resolver.ts index 2bd7a2653..908a211ae 100644 --- a/apps/backend/src/services/org-resolver.ts +++ b/apps/backend/src/services/org-resolver.ts @@ -2,6 +2,7 @@ import { ChannelMemberRepo, ChannelRepo, MessageRepo, OrganizationMemberRepo } f import { PermissionError } from "@hazel/domain" import * as CurrentUser from "@hazel/domain/current-user" import { type ApiScope, CurrentBotScopes, scopesForRole } from "@hazel/domain/scopes" +import type { DatabaseError } from "@hazel/db" import type { ChannelId, MessageId, OrganizationId } from "@hazel/schema" import { Context, Effect, Layer, Option } from "effect" import { isAdminOrOwner, type OrganizationRole } from "../lib/policy-utils" @@ -10,6 +11,9 @@ import { isAdminOrOwner, type OrganizationRole } from "../lib/policy-utils" * OrgResolver centralizes organization membership lookup + scope validation. * It replaces ad-hoc role checks across individual policy services with a single * point of scope resolution. + * + * Lookups fail with the repositories' typed `DatabaseError`; policy boundaries + * (`refailUnauthorized`) turn it into an `InternalServerError`, never a 401. */ export class OrgResolver extends Context.Service()("OrgResolver", { make: Effect.gen(function* () { @@ -43,9 +47,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { ) => Effect.gen(function* () { const actor = yield* CurrentUser.Context - const member = yield* organizationMemberRepo - .findByOrgAndUser(organizationId, actor.id) - .pipe(Effect.orDie) + const member = yield* organizationMemberRepo.findByOrgAndUser(organizationId, actor.id) if (Option.isNone(member)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -69,9 +71,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { ) => Effect.gen(function* () { const actor = yield* CurrentUser.Context - const member = yield* organizationMemberRepo - .findByOrgAndUser(organizationId, actor.id) - .pipe(Effect.orDie) + const member = yield* organizationMemberRepo.findByOrgAndUser(organizationId, actor.id) if (Option.isNone(member)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -93,9 +93,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { ) => Effect.gen(function* () { const actor = yield* CurrentUser.Context - const member = yield* organizationMemberRepo - .findByOrgAndUser(organizationId, actor.id) - .pipe(Effect.orDie) + const member = yield* organizationMemberRepo.findByOrgAndUser(organizationId, actor.id) if (Option.isNone(member) || member.value.role !== "owner") { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -117,7 +115,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { */ const fromChannel = (channelId: ChannelId, scope: ApiScope, entity: string, action: string) => Effect.gen(function* () { - const channel = yield* channelRepo.findById(channelId).pipe(Effect.orDie) + const channel = yield* channelRepo.findById(channelId) if (Option.isNone(channel)) { return yield* Effect.fail( new PermissionError({ message: `Channel not found: ${channelId}` }), @@ -138,7 +136,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { ) => Effect.gen(function* () { const actor = yield* CurrentUser.Context - const channel = yield* channelRepo.findById(channelId).pipe(Effect.orDie) + const channel = yield* channelRepo.findById(channelId) if (Option.isNone(channel)) { return yield* Effect.fail( new PermissionError({ message: `Channel not found: ${channelId}` }), @@ -146,9 +144,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { } const ch = channel.value - const orgMember = yield* organizationMemberRepo - .findByOrgAndUser(ch.organizationId, actor.id) - .pipe(Effect.orDie) + const orgMember = yield* organizationMemberRepo.findByOrgAndUser(ch.organizationId, actor.id) if (Option.isNone(orgMember)) { return yield* Effect.fail(PermissionError.insufficientScope(scope)) @@ -177,7 +173,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { */ const fromMessage = (messageId: MessageId, scope: ApiScope, entity: string, action: string) => Effect.gen(function* () { - const message = yield* messageRepo.findById(messageId).pipe(Effect.orDie) + const message = yield* messageRepo.findById(messageId) if (Option.isNone(message)) { return yield* Effect.fail( new PermissionError({ message: `Message not found: ${messageId}` }), @@ -194,7 +190,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { channel: { type: string; parentChannelId?: string | null; id: string; organizationId: string }, actor: CurrentUser.Schema, orgMember: { role: string }, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { if (channel.type === "public") { return true @@ -204,16 +200,18 @@ export class OrgResolver extends Context.Service()("OrgResolver", { if (isAdminOrOwner(orgMember.role as OrganizationRole)) { return true } - const membership = yield* channelMemberRepo - .findByChannelAndUser(channel.id as ChannelId, actor.id) - .pipe(Effect.orDie) + const membership = yield* channelMemberRepo.findByChannelAndUser( + channel.id as ChannelId, + actor.id, + ) return Option.isSome(membership) } if (channel.type === "direct" || channel.type === "single") { - const membership = yield* channelMemberRepo - .findByChannelAndUser(channel.id as ChannelId, actor.id) - .pipe(Effect.orDie) + const membership = yield* channelMemberRepo.findByChannelAndUser( + channel.id as ChannelId, + actor.id, + ) return Option.isSome(membership) } @@ -221,9 +219,7 @@ export class OrgResolver extends Context.Service()("OrgResolver", { if (!channel.parentChannelId) { return false } - const parentChannel = yield* channelRepo - .findById(channel.parentChannelId as ChannelId) - .pipe(Effect.orDie) + const parentChannel = yield* channelRepo.findById(channel.parentChannelId as ChannelId) if (Option.isNone(parentChannel)) { return false diff --git a/apps/backend/src/services/session-manager.ts b/apps/backend/src/services/session-manager.ts deleted file mode 100644 index 28c0b3f56..000000000 --- a/apps/backend/src/services/session-manager.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { BackendAuth, ClerkClient, type UserRepoLike } from "@hazel/auth" -import { - ClerkUserFetchError, - CurrentUser, - InvalidBearerTokenError, - InvalidJwtPayloadError, -} from "@hazel/domain" -import { OrganizationMemberRepo, OrganizationRepo, UserRepo } from "@hazel/backend-core" -import { Context, Effect, Layer, Option } from "effect" - -/** - * Session management service. Authenticates Clerk bearer tokens and lazily - * syncs a signed-in user's organizations into our DB on auth. - * - * The lazy sync removes the hard webhook dependency for local development — - * `bun run dev` doesn't need an ngrok tunnel pointing at /webhooks/clerk to - * have orgs/memberships appear. Webhooks are still preferred in production - * for instant propagation, but they're no longer required. - */ -export class SessionManager extends Context.Service()("SessionManager", { - make: Effect.gen(function* () { - const auth = yield* BackendAuth - const clerk = yield* ClerkClient - const userRepo = yield* UserRepo - const orgRepo = yield* OrganizationRepo - const membershipRepo = yield* OrganizationMemberRepo - - const userRepoLike: UserRepoLike = { - findByExternalId: userRepo.findByExternalId, - upsertClerkUser: userRepo.upsertClerkUser, - update: userRepo.update, - } - - // Per-process cache: Clerk user ID → last sync epoch-ms. Throttles repeated - // sync calls when a single user fires many requests in quick succession. - const orgSyncCache = new Map() - const SYNC_TTL_MS = 60_000 - - const roleFromClerk = (role: string | undefined | null): "admin" | "member" | "owner" => { - if (role === "org:admin" || role === "admin") return "admin" - return "member" - } - - const syncClerkOrgsForUser = (clerkUserId: string, localUserId: string) => - Effect.gen(function* () { - const lastSync = orgSyncCache.get(clerkUserId) ?? 0 - if (Date.now() - lastSync < SYNC_TTL_MS) return - orgSyncCache.set(clerkUserId, Date.now()) - - const memberships = yield* Effect.tryPromise({ - try: () => - clerk.raw.users.getOrganizationMembershipList({ - userId: clerkUserId, - limit: 100, - }), - catch: (err) => new Error(`clerk.getOrganizationMembershipList failed: ${err}`), - }) - - for (const m of memberships.data) { - const org = m.organization - - // Resolve via Clerk-ID-first lookup so we hit the canonical row even when its - // slug has drifted from Clerk's. A slug-only lookup here was the source of - // duplicate rows being recreated after every dedup pass. - const resolved = yield* orgRepo.upsertFromClerk({ - id: org.id, - name: org.name, - slug: org.slug, - imageUrl: org.imageUrl ?? null, - }) - - if (!resolved) continue - - yield* membershipRepo.upsertByOrgAndUser({ - organizationId: resolved.id, - userId: localUserId as any, - role: roleFromClerk(m.role), - nickname: undefined, - joinedAt: new Date(), - invitedBy: null, - deletedAt: null, - }) - } - }).pipe(Effect.catch((err) => Effect.logWarning(`[session] Clerk org sync failed: ${err}`))) - - const triggerLazySync = (currentUser: CurrentUser.Schema) => - Effect.gen(function* () { - const externalId = yield* userRepo - .findById(currentUser.id) - .pipe(Effect.map((u) => Option.getOrNull(u)?.externalId)) - if (!externalId) return - yield* syncClerkOrgsForUser(externalId, currentUser.id) - }).pipe(Effect.catch(() => Effect.void)) - - const authenticateWithBearer = (bearerToken: string) => - Effect.gen(function* () { - const currentUser = yield* auth.authenticate(bearerToken, userRepoLike) - yield* triggerLazySync(currentUser) - return currentUser - }) - - return { - authenticateWithBearer: authenticateWithBearer as ( - bearerToken: string, - ) => Effect.Effect< - CurrentUser.Schema, - InvalidBearerTokenError | InvalidJwtPayloadError | ClerkUserFetchError, - never - >, - } as const - }), -}) { - static readonly layer = Layer.effect(this, this.make).pipe( - Layer.provide(BackendAuth.layer), - Layer.provide(ClerkClient.layer), - Layer.provide(UserRepo.layer), - Layer.provide(OrganizationRepo.layer), - Layer.provide(OrganizationMemberRepo.layer), - ) -} diff --git a/apps/backend/src/worker/env.ts b/apps/backend/src/worker/env.ts index f1f560d64..51c8d0d3d 100644 --- a/apps/backend/src/worker/env.ts +++ b/apps/backend/src/worker/env.ts @@ -25,12 +25,11 @@ export const apiEnv = ({ stage, urls }: HazelStackContext) => // Sender of invitation emails; its domain must be onboarded to Cloudflare Email Sending. optionalPlain("INVITE_EMAIL_FROM"), - // Auth (Clerk) - requireSecretEntry("CLERK_SECRET_KEY"), - requirePlainEntry("CLERK_PUBLISHABLE_KEY"), - optionalSecret("CLERK_WEBHOOK_SECRET"), + // Maple's OAuth tokens are still Clerk-issued (services/oauth-bearer-auth.ts); API v1 answers + // 503 without this key. + optionalSecret("CLERK_SECRET_KEY"), - // Auth (Hazel, replacing Clerk): off until AUTH_GITHUB_CLIENT_ID is set. Keys come from + // Auth (Hazel sign-in): off until AUTH_GITHUB_CLIENT_ID is set. Keys come from // `bun run --cwd packages/auth generate-keys`. optionalPlain("AUTH_GITHUB_CLIENT_ID"), optionalSecret("AUTH_GITHUB_CLIENT_SECRET"), diff --git a/apps/backend/src/worker/outbox-dispatcher-object.ts b/apps/backend/src/worker/outbox-dispatcher-object.ts index 2cff49387..ba9fee38c 100644 --- a/apps/backend/src/worker/outbox-dispatcher-object.ts +++ b/apps/backend/src/worker/outbox-dispatcher-object.ts @@ -9,7 +9,7 @@ import { bindBotGateways } from "@hazel/bot-gateway/object" import * as Cloudflare from "alchemy/Cloudflare" import { RuntimeContext } from "alchemy/RuntimeContext" -import { Context, Effect, Layer, Ref } from "effect" +import { Cause, Context, Effect, Layer, Ref } from "effect" import { FetchHttpClient } from "effect/http" import { AppServicesLive } from "../app" import { MessageOutboxProcessor, type OutboxBatchResult } from "../services/message-outbox-processor" @@ -81,11 +81,14 @@ export const OutboxDispatcherObjectLive = OutboxDispatcherObject.make( while (again) { yield* Ref.set(rerun, false) const { backlog, retried } = yield* drainOnce.pipe( + // Includes defects: a crashed drain must still re-arm the alarm below. + // Interruption is the object shutting down and passes through. Effect.catchCause((cause) => - Effect.logError("Outbox drain failed", { - workerId, - cause: String(cause), - }).pipe(Effect.as({ backlog: false, retried: 1 })), + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logError("Outbox drain failed", cause, { workerId }).pipe( + Effect.as({ backlog: false, retried: 1 }), + ), ), ) if (backlog || retried > 0) { diff --git a/apps/electric-proxy/resources.ts b/apps/electric-proxy/resources.ts index ad1780678..dfcd89c43 100644 --- a/apps/electric-proxy/resources.ts +++ b/apps/electric-proxy/resources.ts @@ -23,7 +23,7 @@ import type { ElectricContainer } from "./src/electric-container.ts" */ export const ELECTRIC_IMAGE = "docker.io/electricsql/electric-temp:1.8.1" -/** Backs the bot access-context and Clerk user-lookup caches (Redis on the Bun entry). */ +/** Backs the bot access-context cache (Redis on the Bun entry). */ export const ProxyCache = Cloudflare.KV.Namespace( "electric-proxy-cache", stageProps("electric-proxy-cache", (title) => ({ title })), diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json index b634575e9..d78343427 100644 --- a/apps/web-foldkit/package.json +++ b/apps/web-foldkit/package.json @@ -43,8 +43,6 @@ "prosemirror-view": "^1.42.6", "remend": "^1.2.2", "rivetkit": "2.1.6", - "slate": "0.123.0", - "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwind-variants": "^3.2.2" }, @@ -55,7 +53,6 @@ "@tailwindcss/vite": "^4.2.1", "@types/prismjs": "^1.26.6", "tailwindcss": "^4.2.1", - "tailwindcss-react-aria-components": "^2.0.1", "tw-animate-css": "^1.4.0", "typescript": "catalog:tooling", "vite": "8.0.0", diff --git a/apps/web-foldkit/public/images/appearance/dark.svg b/apps/web-foldkit/public/images/appearance/dark.svg new file mode 100644 index 000000000..3f3e78642 --- /dev/null +++ b/apps/web-foldkit/public/images/appearance/dark.svg @@ -0,0 +1 @@ + diff --git a/apps/web-foldkit/public/images/appearance/light.svg b/apps/web-foldkit/public/images/appearance/light.svg new file mode 100644 index 000000000..0bec8020c --- /dev/null +++ b/apps/web-foldkit/public/images/appearance/light.svg @@ -0,0 +1 @@ + diff --git a/apps/web-foldkit/public/images/appearance/system.svg b/apps/web-foldkit/public/images/appearance/system.svg new file mode 100644 index 000000000..668b69feb --- /dev/null +++ b/apps/web-foldkit/public/images/appearance/system.svg @@ -0,0 +1 @@ + diff --git a/apps/web-foldkit/scripts/bench-s2-data.ts b/apps/web-foldkit/scripts/bench-s2-data.ts deleted file mode 100644 index 949a03f77..000000000 --- a/apps/web-foldkit/scripts/bench-s2-data.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { resolve } from "node:path" -import { heapStats } from "bun:jsc" -import { plugin } from "bun" - -/** - * S2 benchmark: (a) the TanStack DB bridge (`page/chat/data.ts` streams over the legacy query - * builders) vs (b) a normalized store in the Model (`data/normalized-store.ts`), on the heavy - * dataset's 10k-message channel. Measures update latency for a new message and a reaction toggle, - * the initial build, heap, and the dev-mode deep-freeze cost. Run from apps/web-foldkit: - * - * bun run scripts/bench-s2-data.ts [--limits 30,300,3000,10000] [--samples 15] - */ - -// `~/db/collections` would open Electric shapes; the benchmark swaps in local collections. -const BENCH_COLLECTIONS = resolve(import.meta.dir, "bench-s2/collections.ts") -plugin({ - name: "bench-local-collections", - setup(build) { - build.onLoad({ filter: /apps\/web\/src\/db\/collections\.ts$/ }, () => ({ - contents: `export * from ${JSON.stringify(BENCH_COLLECTIONS)}`, - loader: "ts", - })) - }, -}) - -const args = new Map( - Bun.argv - .slice(2) - .flatMap((arg, index, all) => (arg.startsWith("--") ? [[arg.slice(2), all[index + 1] ?? ""]] : [])), -) -const limits = (args.get("limits") ?? "30,300,3000,10000").split(",").map(Number) -const samples = Number(args.get("samples") ?? "15") - -const only = args.get("only") -const heapMb = () => { - Bun.gc(true) - return heapStats().heapSize / 1e6 -} - -// `--heap a|b`: heap held by one open channel, in a fresh process per option. -const heapOption = args.get("heap") -if (heapOption) { - await import("../../../packages/ui-parity/src/fixtures/datasets/heavy.ts") - const baseline = heapMb() - const { setupForHeap } = await import( - heapOption === "a" ? "./bench-s2/option-a.ts" : "./bench-s2/option-b.ts" - ) - const retained = await setupForHeap(30) - console.log(JSON.stringify({ option: heapOption, heapMb: Math.round((heapMb() - baseline) * 10) / 10 })) - void retained - process.exit(0) -} - -const results: Array> = [] -if (only !== "b") { - const { runA } = await import("./bench-s2/option-a.ts") - for (const limit of limits) - results.push({ option: "a: TanStack DB bridge", limit, ...(await runA(limit, samples)) }) -} -if (only !== "a") { - const { runB } = await import("./bench-s2/option-b.ts") - for (const limit of limits) - results.push({ option: "b: normalized store", limit, ...(await runB(limit, samples)) }) -} -console.table(results) -console.log(JSON.stringify(results)) -process.exit(0) diff --git a/apps/web-foldkit/scripts/bench-s2/collections.ts b/apps/web-foldkit/scripts/bench-s2/collections.ts deleted file mode 100644 index a8434bc00..000000000 --- a/apps/web-foldkit/scripts/bench-s2/collections.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BTreeIndex, createCollection, localOnlyCollectionOptions } from "@tanstack/db" -import { heavyDataset } from "../../../../packages/ui-parity/src/fixtures/datasets/heavy.ts" - -/** - * Stand-in for `~/db/collections` in the S2 benchmark: the same collection names, seeded with the - * heavy dataset through the sync path (as Electric would), with no network. - */ - -type Row = Record & { readonly id: string } - -const local = (id: string, rows: ReadonlyArray> | undefined) => - createCollection( - localOnlyCollectionOptions({ - id, - // Same indexing as `libs/effect-electric-db-collection` (collection.ts). - autoIndex: "eager", - defaultIndexType: BTreeIndex, - getKey: (row) => row.id, - initialData: (rows ?? []).map((row) => ({ ...row, id: String(row.id) })), - }), - ) - -const tables = heavyDataset.tables - -export const messageCollection = local("messages", tables.messages) -export const pinnedMessageCollection = local("pinned_messages", tables.pinned_messages) -export const userCollection = local("users", tables.users) -export const messageReactionCollection = local("message_reactions", tables.message_reactions) -export const channelCollection = local("channels", tables.channels) diff --git a/apps/web-foldkit/scripts/bench-s2/option-a.ts b/apps/web-foldkit/scripts/bench-s2/option-a.ts deleted file mode 100644 index 1c537522a..000000000 --- a/apps/web-foldkit/scripts/bench-s2/option-a.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { messageCollection, messageReactionCollection } from "~/db/collections" -import * as Data from "../../src/page/chat/data.ts" -import * as Page from "../../src/page/chat/page.ts" -import { - channelId, - currentUserId, - deepFreeze, - freshId, - median, - newMessageRow, - round, - subscribe, - time, -} from "./shared.ts" - -/** - * Option (a): the live TanStack DB queries emit the full result on every change; the page re-derives - * its rows. `dataMs` covers the collection write through the emitted, validated Message. - */ - -export const runA = async (limit: number, samples: number) => { - let constructMs = 0 - const messages = subscribe( - Data.messagesStream(channelId, limit, (rows) => { - const [message, ms] = time(() => Page.Message.UpdatedMessages({ messages: rows })) - constructMs = ms - return message - }), - ) - const reactions = subscribe( - Data.reactionsStream(channelId, (rows) => Page.Message.UpdatedReactions({ reactions: rows })), - ) - - const started = performance.now() - const firstMessages = await messages.next() - const initialDataMs = performance.now() - started - const initialConstructMs = constructMs - const firstReactions = await reactions.next() - let [page, initialUpdateMs] = time(() => { - const withMessages = Page.update(Page.init(channelId, currentUserId), firstMessages).model - return Page.update(withMessages, firstReactions).model - }) - const [, initialFreezeMs] = time(() => deepFreeze(page)) - - const message = { - data: [] as number[], - construct: [] as number[], - update: [] as number[], - freeze: [] as number[], - } - for (let index = 0; index < samples; index++) { - const begin = performance.now() - messageCollection.insert(newMessageRow(index)) - const emitted = await messages.next() - message.data.push(performance.now() - begin) - message.construct.push(constructMs) - const [next, updateMs] = time(() => Page.update(page, emitted).model) - message.update.push(updateMs) - message.freeze.push(time(() => deepFreeze(next))[1]) - page = next - } - - const reaction = { data: [] as number[], update: [] as number[], freeze: [] as number[] } - const target = page.messages[0]! - for (let index = 0; index < samples; index++) { - const id = freshId() - for (const write of [ - () => - messageReactionCollection.insert({ - id, - messageId: target.id, - channelId, - conversationId: null, - userId: currentUserId, - emoji: "🚀", - createdAt: new Date(), - }), - () => messageReactionCollection.delete(id), - ]) { - const begin = performance.now() - write() - const emitted = await reactions.next() - reaction.data.push(performance.now() - begin) - const [next, updateMs] = time(() => Page.update(page, emitted).model) - reaction.update.push(updateMs) - reaction.freeze.push(time(() => deepFreeze(next))[1]) - page = next - } - } - await messages.stop() - await reactions.stop() - - return { - initialMs: round(initialDataMs + initialUpdateMs), - "initial data/construct/update/freeze": [ - initialDataMs, - initialConstructMs, - initialUpdateMs, - initialFreezeMs, - ] - .map(round) - .join(" / "), - newMessageMs: round(median(message.data) + median(message.update)), - "msg data/construct/update/freeze": [message.data, message.construct, message.update, message.freeze] - .map(median) - .join(" / "), - reactionToggleMs: round(median(reaction.data) + median(reaction.update)), - "reaction data/update/freeze": [reaction.data, reaction.update, reaction.freeze] - .map(median) - .join(" / "), - } -} - -/** Everything option (a) keeps alive for one open channel: seeded collections, live queries, the page. */ -export const setupForHeap = async (limit: number) => { - const messages = subscribe( - Data.messagesStream(channelId, limit, (rows) => Page.Message.UpdatedMessages({ messages: rows })), - ) - const reactions = subscribe( - Data.reactionsStream(channelId, (rows) => Page.Message.UpdatedReactions({ reactions: rows })), - ) - const page = Page.update( - Page.update(Page.init(channelId, currentUserId), await messages.next()).model, - await reactions.next(), - ).model - return { messages, reactions, page, collections: [messageCollection, messageReactionCollection] } -} diff --git a/apps/web-foldkit/scripts/bench-s2/option-b.ts b/apps/web-foldkit/scripts/bench-s2/option-b.ts deleted file mode 100644 index 8b5a25a3f..000000000 --- a/apps/web-foldkit/scripts/bench-s2/option-b.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { heavyDataset } from "../../../../packages/ui-parity/src/fixtures/datasets/heavy.ts" -import { - applyChanges, - channelWindow, - emptyStore, - type ShapeChange, - type Store, - StoreMessage, - windowReactions, -} from "../../src/data/normalized-store.ts" -import * as Page from "../../src/page/chat/page.ts" -import { - channelId, - currentUserId, - deepFreeze, - freshId, - median, - newMessageRow, - round, - time, -} from "./shared.ts" - -/** - * Option (b): shape change rows arrive in a validated Message, a reducer folds them into the - * normalized store, and the page re-derives its window from the store (no array-carrying Message). - */ - -const tables = heavyDataset.tables -const asChanges = (table: ShapeChange["table"], rows: ReadonlyArray> | undefined) => - (rows ?? []).map((row) => ({ table, operation: "insert", row }) as unknown as ShapeChange) - -/** The page derives from the store inside update, so its Message is a plain literal here. */ -const derivePage = (page: Page.Model, store: Store, limit: number) => { - const window = channelWindow(store, channelId, limit) - const withMessages = Page.update(page, { _tag: "UpdatedMessages", messages: window }).model - return Page.update(withMessages, { _tag: "UpdatedReactions", reactions: windowReactions(store, window) }) - .model -} - -export const runB = async (limit: number, samples: number) => { - const initialChanges = [ - ...asChanges("users", tables.users), - ...asChanges("messages", tables.messages), - ...asChanges("message_reactions", tables.message_reactions), - ] - const [, initialConstructMs] = time(() => StoreMessage.ReceivedShapeChanges({ changes: initialChanges })) - let [store, initialApplyMs] = time(() => applyChanges(emptyStore, initialChanges)) - let [page, initialDeriveMs] = time(() => derivePage(Page.init(channelId, currentUserId), store, limit)) - const [, initialFreezeMs] = time(() => { - deepFreeze(store) - deepFreeze(page) - }) - - const message = { - construct: [] as number[], - apply: [] as number[], - derive: [] as number[], - freeze: [] as number[], - } - for (let index = 0; index < samples; index++) { - const changes = asChanges("messages", [newMessageRow(index)]) - message.construct.push(time(() => StoreMessage.ReceivedShapeChanges({ changes }))[1]) - const [nextStore, applyMs] = time(() => applyChanges(store, changes)) - const [nextPage, deriveMs] = time(() => derivePage(page, nextStore, limit)) - message.apply.push(applyMs) - message.derive.push(deriveMs) - message.freeze.push( - time(() => { - deepFreeze(nextStore) - deepFreeze(nextPage) - })[1], - ) - store = nextStore - page = nextPage - } - - const reaction = { apply: [] as number[], derive: [] as number[], freeze: [] as number[] } - const target = page.messages[0]! - for (let index = 0; index < samples; index++) { - const row = { id: freshId(), messageId: target.id, userId: currentUserId, emoji: "🚀" } - for (const operation of ["insert", "delete"] as const) { - const changes: ReadonlyArray = [{ table: "message_reactions", operation, row }] - const [nextStore, applyMs] = time(() => applyChanges(store, changes)) - const [nextPage, deriveMs] = time(() => derivePage(page, nextStore, limit)) - reaction.apply.push(applyMs) - reaction.derive.push(deriveMs) - reaction.freeze.push( - time(() => { - deepFreeze(nextStore) - deepFreeze(nextPage) - })[1], - ) - store = nextStore - page = nextPage - } - } - - return { - initialMs: round(initialConstructMs + initialApplyMs + initialDeriveMs), - "initial construct/apply/derive/freeze": [ - initialConstructMs, - initialApplyMs, - initialDeriveMs, - initialFreezeMs, - ] - .map(round) - .join(" / "), - newMessageMs: round(median(message.construct) + median(message.apply) + median(message.derive)), - "msg construct/apply/derive/freeze": [ - message.construct, - message.apply, - message.derive, - message.freeze, - ] - .map(median) - .join(" / "), - reactionToggleMs: round(median(reaction.apply) + median(reaction.derive)), - "reaction apply/derive/freeze": [reaction.apply, reaction.derive, reaction.freeze] - .map(median) - .join(" / "), - } -} - -/** Everything option (b) keeps alive: the normalized store of all synced rows and the page. */ -export const setupForHeap = async (limit: number) => { - const changes = [ - ...asChanges("users", tables.users), - ...asChanges("messages", tables.messages), - ...asChanges("message_reactions", tables.message_reactions), - ] - const store = deepFreeze(applyChanges(emptyStore, changes)) - return { store, page: deepFreeze(derivePage(Page.init(channelId, currentUserId), store, limit)) } -} diff --git a/apps/web-foldkit/scripts/bench-s2/shared.ts b/apps/web-foldkit/scripts/bench-s2/shared.ts deleted file mode 100644 index 3a0d75814..000000000 --- a/apps/web-foldkit/scripts/bench-s2/shared.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { resolve } from "node:path" -import { Effect, Fiber, Stream } from "effect" -import { heavyIds } from "../../../../packages/ui-parity/src/fixtures/datasets/heavy.ts" - -/** Shared helpers for the S2 benchmark: timing, medians, the dev-mode freeze, stream plumbing. */ - -// The runtime's dev-only Model freeze (not a public export), loaded from the installed package. -const { deepFreeze } = (await import( - resolve(import.meta.dir, "../../node_modules/foldkit/dist/runtime/deepFreeze.js") -)) as { - deepFreeze: (value: A) => A -} -export { deepFreeze } - -export const channelId = heavyIds.bigChannelId -export const currentUserId = heavyIds.currentUserId - -export const time = (f: () => A): readonly [A, number] => { - const started = performance.now() - const result = f() - return [result, performance.now() - started] -} - -export const median = (values: ReadonlyArray) => { - const sorted = [...values].sort((a, b) => a - b) - return Math.round((sorted[Math.floor(sorted.length / 2)] ?? 0) * 100) / 100 -} - -export const round = (value: number) => Math.round(value * 100) / 100 - -/** Runs a Message stream in the background; `next()` resolves with the next emission. */ -export const subscribe = (stream: Stream.Stream) => { - const queued: A[] = [] - let waiting: ((value: A) => void) | undefined - const fiber = Effect.runFork( - Stream.runForEach(stream, (value) => - Effect.sync(() => { - if (waiting) { - const resolve = waiting - waiting = undefined - resolve(value) - } else queued.push(value) - }), - ), - ) - return { - next: () => - new Promise((resolve) => { - const value = queued.shift() - if (value !== undefined) resolve(value) - else waiting = resolve - }), - stop: () => Effect.runPromise(Fiber.interrupt(fiber)), - } -} - -let counter = 0 -/** A fresh uuid-shaped id that never collides with the dataset's v5 ids. */ -export const freshId = () => `ffffffff-0000-4000-8000-${String(++counter).padStart(12, "0")}` - -/** A new message at the end of the big channel. */ -export const newMessageRow = (index: number) => ({ - id: freshId(), - channelId, - conversationId: null, - authorId: currentUserId, - content: `New message ${index} with **some** markdown`, - embeds: null, - replyToMessageId: null, - threadChannelId: null, - createdAt: new Date(Date.UTC(2026, 2, 12, 16, 0) + index * 1000), - updatedAt: null, - deletedAt: null, -}) diff --git a/apps/web-foldkit/src/app/auth.ts b/apps/web-foldkit/src/app/auth.ts index 67d69e4cc..b8905eba4 100644 --- a/apps/web-foldkit/src/app/auth.ts +++ b/apps/web-foldkit/src/app/auth.ts @@ -1,24 +1,33 @@ import { Effect, Schedule, Schema } from "effect" import { clearAccessToken } from "~/lib/access-token" +import { CommonReason, reasonOfUnknown } from "~/lib/error-messages" import { HazelAuthClient } from "../auth/client" import { isDesktop } from "../platform/desktop" import { runDesktopAuth } from "../platform/desktop-auth" -import type { Auth } from "../session" +import type { SessionAnswer } from "./message" /** * The Hazel session, read from the API. In a browser it is an HttpOnly cookie on the API host * (`getSession`); the desktop app holds its own session in the OS keychain. */ -/** Retried a few times while the API is unreachable; auth stays `Loading` (the app loader) meanwhile. */ -const retryUnavailable = Schedule.min([ - Schedule.exponential("500 millis").pipe(Schedule.jittered), - Schedule.spaced("30 seconds"), -]) +/** + * The API (or the desktop keychain) could not answer. The auth client's errors are not part of the + * app's `CommonError` union, so the reason is classified once here: offline, or unavailable. + */ +export class SessionCheckError extends Schema.TaggedError()("SessionCheckError", { + reason: CommonReason, +}) {} + +const toSessionCheckError = (cause: unknown) => + new SessionCheckError({ reason: reasonOfUnknown(cause) === "Offline" ? "Offline" : "Unavailable" }) + +/** A momentary blip right after load is retried before the app says it cannot reach Hazel. */ +const retryBlip = Schedule.exponential("500 millis").pipe(Schedule.jittered) /** The desktop app's own session (OS keychain), checked with the API. */ const readDesktopSession = runDesktopAuth((auth) => auth.session).pipe( - Effect.map((session): Auth => (session === null ? "SignedOut" : "SignedIn")), + Effect.map((session): SessionAnswer => (session === null ? "SignedOut" : "SignedIn")), ) const readBrowserSession = Effect.gen(function* () { @@ -27,37 +36,31 @@ const readBrowserSession = Effect.gen(function* () { return session === null ? ("SignedOut" as const) : ("SignedIn" as const) }) -const read: Effect.Effect = isDesktop - ? readDesktopSession - : readBrowserSession - /** - * Fails only when the API could not answer at all. An unreachable API says nothing about the - * session, so it must not count as signed out: that would send a signed-in user to sign-in. + * Fails only when nothing answered. An unreachable API says nothing about the session, so it must + * not count as signed out: that would send a signed-in user to sign-in. */ -export const readSession: Effect.Effect = read.pipe( - Effect.tapError((error) => Effect.logWarning("[auth] session check failed", error)), - Effect.retry({ schedule: retryUnavailable, times: 3 }), +export const readSession: Effect.Effect = ( + isDesktop ? readDesktopSession : readBrowserSession +).pipe( + Effect.mapError(toSessionCheckError), + Effect.tapError((error) => Effect.logWarning("[auth] session check failed", error.reason)), + Effect.retry({ schedule: retryBlip, times: 2 }), ) /** The API rejected sign-out or could not be reached; the user stays signed in. */ export class SignOutError extends Schema.TaggedError()("SignOutError", { - message: Schema.String, + reason: CommonReason, }) {} -/** Ends the session on the API, then reloads `/`, which sends the browser to sign-in. */ +/** Ends the session on the API and drops the cached access token. */ export const signOut: Effect.Effect = Effect.gen(function* () { if (isDesktop) { // Ends only the desktop app's session; the browser it signed in with stays signed in. - yield* runDesktopAuth((auth) => auth.signOut).pipe( - Effect.mapError((error) => new SignOutError({ message: error._tag })), - ) + yield* runDesktopAuth((auth) => auth.signOut) } else { const client = yield* HazelAuthClient - yield* client.auth - .signOut() - .pipe(Effect.mapError((error) => new SignOutError({ message: error._tag }))) + yield* client.auth.signOut() } - clearAccessToken() - yield* Effect.sync(() => window.location.assign("/")) -}) + yield* Effect.sync(clearAccessToken) +}).pipe(Effect.mapError((cause) => new SignOutError({ reason: reasonOfUnknown(cause) }))) diff --git a/apps/web-foldkit/src/app/command.ts b/apps/web-foldkit/src/app/command.ts index a9e8cb42a..1ddeadf27 100644 --- a/apps/web-foldkit/src/app/command.ts +++ b/apps/web-foldkit/src/app/command.ts @@ -1,14 +1,15 @@ +import { NotificationId } from "@hazel/schema" import { Duration, Effect, Schedule, Schema } from "effect" import { Command } from "foldkit" import { load, pushUrl, replaceUrl } from "foldkit/navigation" +import { isRetryableError } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" +import { saveSoundSettings, SoundSettings } from "../notification-sound" import { HazelRpc } from "../rpc" import { applyTheme, ResolvedTheme, saveThemePreference, ThemeCustomization, ThemePreference } from "../theme" -import { saveSoundSettings, SoundSettings } from "../notification-sound" -import { deliverNotifications } from "./notification-sinks" -import { UnauthorizedError } from "@hazel/domain/errors" -import { NotificationId } from "@hazel/schema" import { readSession, signOut } from "./auth" import { Message } from "./message" +import { deliverNotifications } from "./notification-sinks" export const NavigateInternal = Command.define("NavigateInternal", { args: { url: Schema.String }, @@ -23,6 +24,7 @@ export const ReplaceUrl = Command.define("ReplaceUrl", { execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedReplaceUrl())), }) +/** A full page load of `href`: another site, or the app itself after sign-out so every cache starts over. */ export const LoadExternal = Command.define("LoadExternal", { args: { href: Schema.String }, messages: [Message.CompletedLoadExternal], @@ -38,15 +40,22 @@ export const ApplyTheme = Command.define("ApplyTheme", { export const SaveThemePreference = Command.define("SaveThemePreference", { args: { preference: ThemePreference }, - messages: [Message.CompletedSaveThemePreference], + messages: [Message.CompletedSaveThemePreference, Message.FailedSaveThemePreference], execute: ({ preference }) => - saveThemePreference(preference).pipe(Effect.as(Message.CompletedSaveThemePreference())), + saveThemePreference(preference).pipe( + Effect.as(Message.CompletedSaveThemePreference()), + Effect.catchTag("StorageWriteError", () => Effect.succeed(Message.FailedSaveThemePreference())), + ), }) export const SaveSoundSettings = Command.define("SaveSoundSettings", { args: { settings: SoundSettings }, - messages: [Message.CompletedSaveSoundSettings], - execute: ({ settings }) => saveSoundSettings(settings).pipe(Effect.as(Message.CompletedSaveSoundSettings())), + messages: [Message.CompletedSaveSoundSettings, Message.FailedSaveSoundSettings], + execute: ({ settings }) => + saveSoundSettings(settings).pipe( + Effect.as(Message.CompletedSaveSoundSettings()), + Effect.catchTag("StorageWriteError", () => Effect.succeed(Message.FailedSaveSoundSettings())), + ), }) /** `notificationOrchestrator.enqueue(events)`; it skips what it has already processed. */ @@ -57,11 +66,14 @@ export const DeliverNotifications = Command.define("DeliverNotifications", { }) /** How long the app waits before asking again after the API did not answer. */ -const RETRY_DELAY = Duration.seconds(10) +export const RETRY_DELAY = Duration.seconds(10) const checkSession = readSession.pipe( Effect.map((auth) => Message.ChangedAuth({ auth })), - Effect.orElseSucceed(() => Message.FailedCheckSession()), + Effect.catchTag("SessionCheckError", ({ reason }) => + Effect.succeed(Message.FailedCheckSession({ reason })), + ), + catchCommon((reason) => Message.FailedCheckSession({ reason })), ) /** Whether the API has a session for this browser. */ @@ -83,26 +95,21 @@ export const Reload = Command.define("Reload", { }) export const SignOut = Command.define("SignOut", { - messages: [Message.CompletedSignOut, Message.FailedSignOut], + messages: [Message.SucceededSignOut, Message.FailedSignOut], execute: signOut.pipe( - Effect.as(Message.CompletedSignOut()), - Effect.catchTag("SignOutError", (error) => - Effect.succeed(Message.FailedSignOut({ reason: error.message })), - ), + Effect.as(Message.SucceededSignOut()), + Effect.catchTag("SignOutError", ({ reason }) => Effect.succeed(Message.FailedSignOut({ reason }))), + catchCommon((reason) => Message.FailedSignOut({ reason })), ), }) -/** A failed `user.me` other than Unauthorized is retried this often before reporting failure. */ -const retryCurrentUser = Schedule.exponential("500 millis").pipe(Schedule.jittered) +/** A momentary `user.me` failure is retried in place before it is reported. */ +const retryBlip = Schedule.exponential("500 millis").pipe(Schedule.jittered) const fetchCurrentUser = Effect.gen(function* () { const client = yield* HazelRpc const user = yield* client("user.me", undefined).pipe( - Effect.retry({ - schedule: retryCurrentUser, - times: 3, - while: (error) => !UnauthorizedError.is(error), - }), + Effect.retry({ schedule: retryBlip, times: 2, while: isRetryableError }), ) return Message.SucceededFetchCurrentUser({ user: { @@ -115,23 +122,14 @@ const fetchCurrentUser = Effect.gen(function* () { organizationId: user.organizationId ?? null, }, }) -}).pipe( - Effect.catch((error) => - Effect.succeed( - Message.FailedFetchCurrentUser({ - reason: String(error), - isUnauthorized: UnauthorizedError.is(error), - }), - ), - ), -) +}).pipe(catchCommon((reason) => Message.FailedFetchCurrentUser({ reason }))) export const FetchCurrentUser = Command.define("FetchCurrentUser", { messages: [Message.SucceededFetchCurrentUser, Message.FailedFetchCurrentUser], execute: fetchCurrentUser, }) -/** `user.me` again after a pause, while the shell has a session but no user to show. */ +/** `user.me` again after a pause, while the API keeps failing to answer it. */ export const RetryFetchCurrentUser = Command.define("RetryFetchCurrentUser", { messages: [Message.SucceededFetchCurrentUser, Message.FailedFetchCurrentUser], execute: Effect.sleep(RETRY_DELAY).pipe(Effect.andThen(fetchCurrentUser)), diff --git a/apps/web-foldkit/src/app/desktop.test.ts b/apps/web-foldkit/src/app/desktop.test.ts index 3ee657d16..adfefc80b 100644 --- a/apps/web-foldkit/src/app/desktop.test.ts +++ b/apps/web-foldkit/src/app/desktop.test.ts @@ -1,9 +1,10 @@ // @vitest-environment jsdom +import { Option } from "effect" import { describe, expect, test } from "vitest" import { update } from "../main" +import * as Toaster from "../overlay/toaster" import * as Updates from "../platform/updates" import { signedIn } from "../test/root-fixtures" -import * as Toast from "../ui/toast" import { Message } from "./message" /** The desktop app's update toast and menu, through the root. */ @@ -15,16 +16,23 @@ const found = Message.GotUpdatesMessage({ describe("desktop updates", () => { test("an available update shows a sticky toast whose button installs it", () => { const available = update(signedIn("/hazel/chat"), found).model - const toast = available.toasts.toaster.toasts.find( - (item) => item.title === "Update available: v1.2.0", + const toast = available.toasts.entries.find( + (entry) => entry.payload.title === "Update available: v1.2.0", ) - expect(toast).toMatchObject({ actionLabel: "Install & Restart", duration: null }) + expect(toast?.payload.maybeAction).toEqual( + Option.some({ label: "Install & Restart", key: "InstallUpdate" }), + ) + expect(toast?.maybeDuration).toEqual(Option.none()) const clicked = update( available, - Message.GotToastsMessage({ message: Toast.Message.ClickedAction({ id: toast!.id }) }), + Message.GotToastsMessage({ + message: Toaster.Message.ClickedAction({ entryId: toast!.id, action: "InstallUpdate" }), + }), ) expect(clicked.model.desktopUpdate._tag).toBe("Downloading") + // The clicked toast leaves; the download progress shows as a new loading toast. + expect(clicked.model.toasts.entries.map((entry) => entry.payload.kind)).toEqual(["info", "loading"]) }) test("the menu's Check for Updates starts a check", () => { diff --git a/apps/web-foldkit/src/app/loading.test.ts b/apps/web-foldkit/src/app/loading.test.ts index 66598f6d3..7fbccd1f7 100644 --- a/apps/web-foldkit/src/app/loading.test.ts +++ b/apps/web-foldkit/src/app/loading.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { Option, Schema } from "effect" +import * as Scene from "foldkit/scene" import { Mount, expect as sceneExpect, given, role, scene, text } from "foldkit/scene" import { fromString } from "foldkit/url" import { describe, expect, test } from "vitest" @@ -9,8 +10,10 @@ import { DEFAULT_SOUND_SETTINGS } from "../notification-sound" import { defaultThemePreference } from "../theme" import * as Menu from "../ui/menu" import { FocusTriggerOnPress } from "../ui/menu-view" +import { urlOf } from "../test/root-fixtures" +import { CheckSession, FetchCurrentUser } from "./command" import { Message } from "./message" -import type { Model } from "./model" +import { type Model, sharedOf } from "./model" import { view } from "./view" /** `_app/layout.tsx` and `$orgSlug/layout.tsx`: the full-page loader until `user.me` and the org answer. */ @@ -41,6 +44,8 @@ const boot = (path: string): Model => systemTheme: "light", soundSettings: DEFAULT_SOUND_SETTINGS, sessionStartMs: 0, + isMobile: false, + isCreateChannelHintDismissed: false, }, url, ).model, @@ -52,6 +57,7 @@ const after = (model: Model, messages: ReadonlyArray) => const config = { update, view: (model: Model, h: Parameters[1]) => view(model, h).body } const loader = role("progressbar", { name: "Loading" }) const reload = role("button", { name: "Reload" }) +const tryAgain = role("button", { name: "Try again" }) /** Whether the root renders the loader (``). */ const expectLoader = (model: Model, isShown: boolean) => @@ -117,18 +123,39 @@ describe("root loading states", () => { expectLoader(loaded, false) }) - test("an unanswered session check keeps the loader and offers a reload", () => { - const stalled = after(boot("/hazel/settings/team"), [Message.FailedCheckSession()]) - expect(stalled.auth).toBe("Loading") - scene(config, given(stalled), sceneExpect(loader).toExist(), sceneExpect(reload).toExist()) + // P0: an unreachable API is not "signed out": the app says it can't reach Hazel and offers Try again. + test("an unanswered session check shows the error with Try again, never sign-in", () => { + const unreachable = after(boot("/hazel/settings/team"), [ + Message.FailedCheckSession({ reason: "Offline" }), + ]) + scene( + config, + given(unreachable), + sceneExpect(loader).toBeAbsent(), + sceneExpect(text("Can't reach Hazel")).toExist(), + sceneExpect(tryAgain).toExist(), + Scene.click(tryAgain), + Scene.Command.expectHas(CheckSession()), + Scene.Command.resolve(CheckSession, Message.ChangedAuth({ auth: "SignedIn" })), + Scene.Command.resolve(FetchCurrentUser, Message.SucceededFetchCurrentUser({ user: ada })), + sceneExpect(loader).toExist(), + ) }) - test("a failing user.me keeps the loader and offers a reload", () => { - const stalled = after(boot("/hazel/settings/team"), [ + test("a user.me that keeps failing shows the error once its retries ran out", () => { + const offline = Message.FailedFetchCurrentUser({ reason: "Offline" }) + const retrying = after(boot("/hazel/settings/team"), [ Message.ChangedAuth({ auth: "SignedIn" }), - Message.FailedFetchCurrentUser({ reason: "offline", isUnauthorized: false }), + offline, ]) - scene(config, given(stalled), sceneExpect(loader).toExist(), sceneExpect(reload).toExist()) + scene(config, given(retrying), sceneExpect(loader).toExist()) + const failed = after(retrying, [offline, offline, offline]) + scene( + config, + given(failed), + sceneExpect(text("Couldn't load your account")).toExist(), + sceneExpect(tryAgain).toExist(), + ) }) test("switching organizations drops the previous organization's role", () => { @@ -142,14 +169,20 @@ describe("root loading states", () => { role: "owner" as const, } const inHazel = after(boot("/hazel/settings/team"), [ + Message.ChangedAuth({ auth: "SignedIn" }), + Message.SucceededFetchCurrentUser({ user: ada }), Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazel }), - Message.UpdatedMember({ member }), + Message.UpdatedMember({ organizationId: hazel.id, member }), ]) expect( - after(inHazel, [Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazel })]).member, + sharedOf(after(inHazel, [Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazel })])) + .member, ).toEqual(member) - expect( - after(inHazel, [Message.UpdatedOrganization({ orgSlug: "acme", organization: acme })]).member, - ).toBeNull() + const inAcme = after(inHazel, [ + Message.ChangedUrl({ url: urlOf("/acme/settings/team") }), + Message.UpdatedOrganization({ orgSlug: "acme", organization: acme }), + ]) + expect(sharedOf(inAcme).organization).toEqual(acme) + expect(sharedOf(inAcme).member).toBeNull() }) }) diff --git a/apps/web-foldkit/src/app/message.ts b/apps/web-foldkit/src/app/message.ts index 66522b7a4..a23676ea2 100644 --- a/apps/web-foldkit/src/app/message.ts +++ b/apps/web-foldkit/src/app/message.ts @@ -1,20 +1,26 @@ -import { NotificationId } from "@hazel/schema" +import { NotificationId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { UrlRequest } from "foldkit/navigation" import { Url } from "foldkit/url" +import { CommonReason } from "~/lib/error-messages" import * as CommandPalette from "../overlay/command-palette" import { LayoutHotkeyActionId } from "../overlay/hotkeys" import * as Modal from "../overlay/modal" import * as Toasts from "../overlay/toaster" import { Message as PageMessage } from "../page/registry" -import { Auth, CurrentUser, Member, Organization } from "../session" +import { AppRoute } from "../route" +import { CurrentUser, Member, Organization } from "../session" import * as Platform from "../platform" import { DesktopMenuItem } from "../platform/desktop-menu" import * as Updates from "../platform/updates" import * as Shell from "../shell/model" import { ResolvedTheme } from "../theme" +/** What the API said about the session, when it answered. */ +export const SessionAnswer = Schema.Literals(["SignedIn", "SignedOut"]) +export type SessionAnswer = typeof SessionAnswer.Type + export const Message = defineMessageUnion({ ClickedLink: { request: UrlRequest }, ChangedUrl: { url: Url }, @@ -22,30 +28,37 @@ export const Message = defineMessageUnion({ CompletedReplaceUrl: {}, CompletedLoadExternal: {}, ChangedSystemTheme: { theme: ResolvedTheme }, + /** `useSidebar().isMobile`: the `(max-width: 767px)` media query changed. */ + ChangedViewport: { isMobile: Schema.Boolean }, CompletedApplyTheme: {}, CompletedSaveThemePreference: {}, + FailedSaveThemePreference: {}, CompletedSaveSoundSettings: {}, + FailedSaveSoundSettings: {}, /** The member's latest notifications (newest first), for the sound and native sinks. */ UpdatedRecentNotifications: { ids: Schema.Array(NotificationId) }, CompletedDeliverNotifications: {}, - ChangedAuth: { auth: Auth }, - /** The API did not answer the session check; auth stays `Loading` and the check runs again. */ - FailedCheckSession: {}, + ChangedAuth: { auth: SessionAnswer }, + /** The API did not answer the session check: nothing is known about the session. */ + FailedCheckSession: { reason: CommonReason }, + PressedRetrySession: {}, /** The API refused to exchange the session for an access token. */ RejectedSession: {}, SucceededFetchCurrentUser: { user: CurrentUser }, - FailedFetchCurrentUser: { reason: Schema.String, isUnauthorized: Schema.Boolean }, - CompletedSignOut: {}, - FailedSignOut: { reason: Schema.String }, + FailedFetchCurrentUser: { reason: CommonReason }, + PressedRetryCurrentUser: {}, + SucceededSignOut: {}, + FailedSignOut: { reason: CommonReason }, /** The organization query for `orgSlug` answered (found or not). */ UpdatedOrganization: { orgSlug: Schema.String, organization: Schema.NullOr(Organization) }, /** The organization query for `orgSlug` has not loaded after a while, or its source failed. */ StalledOrganization: { orgSlug: Schema.String }, PressedReload: {}, CompletedReload: {}, - UpdatedMember: { member: Schema.NullOr(Member) }, - TickedPresenceClock: { nowMs: Schema.Number }, - ClickedLayoutTab: { href: Schema.String }, + /** The user's row in `organizationId` (null when they have none). */ + UpdatedMember: { organizationId: OrganizationId, member: Schema.NullOr(Member) }, + TickedClock: { nowMs: Schema.Number }, + ClickedLayoutTab: { route: AppRoute }, GotPageMessage: { message: PageMessage }, GotShellMessage: { message: Shell.Message }, GotModalMessage: { message: Modal.Message }, diff --git a/apps/web-foldkit/src/app/modal-host.scene.test.ts b/apps/web-foldkit/src/app/modal-host.scene.test.ts index 4c3531a60..c00816088 100644 --- a/apps/web-foldkit/src/app/modal-host.scene.test.ts +++ b/apps/web-foldkit/src/app/modal-host.scene.test.ts @@ -1,13 +1,28 @@ // @vitest-environment jsdom -import { Command, Mount, click, expect as sceneExpect, given, label, role, scene, submit, text, type, within, selector } from "foldkit/scene" +import { + Command, + Mount, + click, + expect as sceneExpect, + given, + label, + role, + scene, + submit, + text, + type, + within, + selector, +} from "foldkit/scene" import { describe, test } from "vitest" import * as CreateSection from "../overlay/modal/create-section" +import { orgSwitcherEntries } from "../shell/menus" import * as Shell from "../shell/model" import { after, hazelOrg, rootScene, signedIn } from "../test/root-fixtures" +import { drainToast } from "../test/toast-fixtures" import * as Menu from "../ui/menu" import { FocusTriggerOnPress } from "../ui/menu-view" import * as Modal from "../ui/modal" -import * as Toast from "../ui/toast" import { Message } from "./message" /** The root modal slot: a modal opened by the shell, its result toast, and closing (legacy `useModal`). */ @@ -17,14 +32,25 @@ const orgSwitcher = (inner: Menu.Message): Message => const withCreateSection = after(signedIn("/hazel/chat"), [ orgSwitcher(Menu.Message.PressedTrigger({ pointerType: "mouse" })), - orgSwitcher(Menu.Message.ClickedItem({ key: "create-category" })), + orgSwitcher( + Menu.Message.ClickedItem({ + key: "create-category", + entries: orgSwitcherEntries({ + orgSlug: "hazel", + isChat: true, + isMobile: false, + canCreateChannel: true, + organizations: [], + }), + }), + ), ]) const dialog = role("dialog", { name: "Create a new Section" }) const mounted = [ Mount.resolve(FocusTriggerOnPress, Menu.Message.CompletedFocusTriggerOnPress()), Mount.resolve(FocusTriggerOnPress, Menu.Message.CompletedFocusTriggerOnPress()), - Mount.resolve(Modal.PortalModal, Modal.Message.CompletedPortalModal()), + Mount.resolve(Modal.AcquireResources, Modal.Message.SucceededAcquireResources()), ] as const describe("modal host", () => { @@ -46,13 +72,9 @@ describe("modal host", () => { sceneExpect(role("button", { name: "Creating..." })).toBeDisabled(), Command.resolve(CreateSection.CreateSection, CreateSection.Message.SucceededCreateSection()), sceneExpect(dialog).toBeAbsent(), - Mount.expectEnded(Modal.PortalModal), + Mount.expectEnded(Modal.AcquireResources), sceneExpect(text("Section created successfully")).toExist(), - Mount.resolve(Toast.MeasureToast, Toast.Message.MeasuredToast({ id: 1, height: 52 })), - Command.resolve(Toast.StartToastTimer, Toast.Message.StartedTimer({ id: 1, version: 1, at: 0 })), - Command.resolve(Toast.WaitForToastLifetime, Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 })), - Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), - Mount.expectEnded(Toast.MeasureToast), + ...drainToast(), ) }) @@ -64,15 +86,14 @@ describe("modal host", () => { ...mounted, type(label("Section Name"), "Design"), submit(within(dialog, selector("form"))), - Command.resolve(CreateSection.CreateSection, CreateSection.Message.FailedCreateSection({ toast: failed })), + Command.resolve( + CreateSection.CreateSection, + CreateSection.Message.FailedCreateSection({ toast: failed }), + ), sceneExpect(dialog).toExist(), sceneExpect(role("button", { name: "Create section" })).toBeEnabled(), sceneExpect(text("Could not create the section")).toExist(), - Mount.resolve(Toast.MeasureToast, Toast.Message.MeasuredToast({ id: 1, height: 52 })), - Command.resolve(Toast.StartToastTimer, Toast.Message.StartedTimer({ id: 1, version: 1, at: 0 })), - Command.resolve(Toast.WaitForToastLifetime, Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 })), - Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), - Mount.expectEnded(Toast.MeasureToast), + ...drainToast(), ) }) @@ -83,7 +104,7 @@ describe("modal host", () => { ...mounted, click(role("button", { name: "Cancel" })), sceneExpect(dialog).toBeAbsent(), - Mount.expectEnded(Modal.PortalModal), + Mount.expectEnded(Modal.AcquireResources), Command.expectNone(), ) }) diff --git a/apps/web-foldkit/src/app/model.ts b/apps/web-foldkit/src/app/model.ts index 215174e8e..473b48e74 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -1,44 +1,83 @@ -import { Schema } from "effect" +import { Option, Schema } from "effect" +import { AsyncData } from "foldkit" +import { defineTaggedUnion } from "foldkit/schema" +import { CommonReason } from "~/lib/error-messages" import * as CommandPalette from "../overlay/command-palette" import * as Modal from "../overlay/modal" import * as Toasts from "../overlay/toaster" import type { PageHost, Shared } from "../page/contract" +import { can } from "../page/contract" import { PageSlot } from "../page/registry" import { AppRoute, orgSectionOf, orgSlugOf } from "../route" -import { Auth, CurrentUser, Member, Organization } from "../session" +import { type Auth, CurrentUser, Member, Organization } from "../session" import { SoundSettings } from "../notification-sound" import * as Platform from "../platform" import * as Updates from "../platform/updates" import * as Shell from "../shell/model" -import { ResolvedTheme, resolveTheme, ThemePreference } from "../theme" import type { Context as ShellUpdateContext } from "../shell/update" -import { can } from "../page/contract" +import { ResolvedTheme, resolveTheme, ThemePreference } from "../theme" + +// SESSION + +/** `user.me` for the signed-in session; a failure keeps its reason for the error screen. */ +export const CurrentUserData = AsyncData.Schema(CurrentUser, CommonReason) +export type CurrentUserData = typeof CurrentUserData.schema.Type + +/** + * Whether the API has a session for this browser. `Unreachable` is not signed out: the API did not + * answer, so nothing is known about the session and the app offers to try again. + */ +export const Session = defineTaggedUnion({ + Loading: {}, + Unreachable: { reason: CommonReason, retries: Schema.Number }, + SignedOut: {}, + /** `retries`: automatic `user.me` retries so far; past the limit the error screen offers one by hand. */ + SignedIn: { user: CurrentUserData.schema, retries: Schema.Number }, +}) +export type Session = typeof Session.Type + +// ORGANIZATION + +/** The user's row in the loaded organization (`usePermission`); `null` when they have none. */ +export const MemberData = AsyncData.Schema(Schema.NullOr(Member), Schema.Never) +export type MemberData = typeof MemberData.schema.Type /** - * The organization query for `slug`: `Found` (the shell renders), `NotFound` (no such org, or the - * user is not a member) or `Stalled` (not loaded after a while; the loader offers a reload). + * The organization the route names (`useOrganization()`), keyed by its slug: a switch to another + * slug starts over at `Loading`, so the previous organization and role never show under the new one. */ -export const OrgLoad = Schema.Struct({ - slug: Schema.String, - status: Schema.Literals(["Found", "NotFound", "Stalled"]), +export const OrgState = defineTaggedUnion({ + /** The route names no organization. */ + None: {}, + /** `isStalled`: not loaded after a while; the loader offers a reload. */ + Loading: { slug: Schema.String, isStalled: Schema.Boolean }, + /** No such organization, or the user is not a member. */ + NotFound: { slug: Schema.String }, + Loaded: { slug: Schema.String, organization: Organization, member: MemberData.schema }, }) -export type OrgLoad = typeof OrgLoad.Type +export type OrgState = typeof OrgState.Type + +/** The org state a route starts with: kept while the slug stays, `Loading` for a new one. */ +export const orgStateFor = (current: OrgState, route: AppRoute): OrgState => { + const slug = orgSlugOf(route) + if (slug === undefined) return OrgState.None() + return current._tag !== "None" && current.slug === slug + ? current + : OrgState.Loading({ slug, isStalled: false }) +} + +// MODEL /** The root Model: route, session, the active page, the shell and the overlay slots. */ export const Model = Schema.Struct({ route: AppRoute, - pathname: Schema.String, /** `pathname + search`, for `redirect_url`. */ currentUrl: Schema.String, - auth: Auth, - currentUser: Schema.NullOr(CurrentUser), - organization: Schema.NullOr(Organization), - /** How far the route's organization has loaded; until `slug` matches the route, a loader shows. */ - orgLoad: Schema.NullOr(OrgLoad), - /** The session check or `user.me` keeps failing: the loader offers a reload while retries go on. */ - isSessionStalled: Schema.Boolean, - isCurrentUserStalled: Schema.Boolean, - member: Schema.NullOr(Member), + session: Session, + org: OrgState, + /** `useSidebar().isMobile`: the `(max-width: 767px)` viewport. */ + isMobile: Schema.Boolean, + /** Wall clock for presence and status expiry, ticking while the signed-in shell shows. */ nowMs: Schema.Number, /** The stored theme preference (`themeAtom`, `themeCustomizationAtom`), and the system's theme. */ themePreference: ThemePreference, @@ -52,27 +91,58 @@ export const Model = Schema.Struct({ modal: Modal.Model, commandPalette: CommandPalette.Model, toasts: Toasts.Model, - /** Presence and the Rivet client (legacy `PresenceProvider`, `lib/rivet-client.ts`). */ + /** Presence (legacy `PresenceProvider`). */ platform: Platform.Model, /** The desktop app's update check and install. */ desktopUpdate: Updates.Model, }) export type Model = typeof Model.Type +// DERIVED + export const resolvedThemeOf = (model: Model): ResolvedTheme => resolveTheme(model.themePreference.mode, model.systemTheme) +export const authOf = (session: Session): Auth => + Session.match(session, { + Loading: (): Auth => "Loading", + Unreachable: (): Auth => "Loading", + SignedOut: (): Auth => "SignedOut", + SignedIn: (): Auth => "SignedIn", + }) + +/** The signed-in user once `user.me` has answered. */ +export const currentUserOf = (model: Model): CurrentUser | null => + model.session._tag === "SignedIn" ? Option.getOrNull(AsyncData.getData(model.session.user)) : null + +/** The organization the route names, once loaded; never another slug's. */ +export const loadedOrgOf = (model: Model): Option.Option> => + model.org._tag === "Loaded" && model.org.slug === orgSlugOf(model.route) + ? Option.some(model.org) + : Option.none() + +export const organizationOf = (model: Model): Organization | null => + Option.getOrNull(Option.map(loadedOrgOf(model), (org) => org.organization)) + +export const memberOf = (model: Model): Member | null => + Option.getOrNull( + Option.flatMap(loadedOrgOf(model), (org) => + Option.flatMapNullishOr(AsyncData.getData(org.member), (m) => m), + ), + ) + export const sharedOf = (model: Model): Shared => ({ - auth: model.auth, + auth: authOf(model.session), orgSlug: orgSlugOf(model.route) ?? null, - currentUser: model.currentUser, - organization: model.organization, - member: model.member, + currentUser: currentUserOf(model), + organization: organizationOf(model), + member: memberOf(model), nowMs: model.nowMs, - isMobile: model.shell.isMobile, + isMobile: model.isMobile, theme: { ...model.themePreference, resolved: resolvedThemeOf(model) }, soundSettings: model.soundSettings, desktopUpdate: model.desktopUpdate, + isOverlayOpen: model.modal.current !== null || model.commandPalette.isOpen, }) export const pageHostOf = (model: Model): PageHost => ({ page: model.page, shared: sharedOf(model) }) @@ -81,6 +151,7 @@ export const shellContextOf = (model: Model): ShellUpdateContext => ({ orgSlug: orgSlugOf(model.route) ?? null, isChatSection: orgSectionOf(model.route) === "Chat", canCreateChannel: can(sharedOf(model), "channel.create"), - organizationId: model.organization?.id ?? null, - currentUserId: model.currentUser?.id ?? null, + organizationId: organizationOf(model)?.id ?? null, + currentUserId: currentUserOf(model)?.id ?? null, + isMobile: model.isMobile, }) diff --git a/apps/web-foldkit/src/app/notification-sinks.ts b/apps/web-foldkit/src/app/notification-sinks.ts index db96ea467..a4616444d 100644 --- a/apps/web-foldkit/src/app/notification-sinks.ts +++ b/apps/web-foldkit/src/app/notification-sinks.ts @@ -1,30 +1,40 @@ import { ChannelId, MessageId, type NotificationId, type UserId } from "@hazel/schema" -import { Effect, Option, Schema, Stream } from "effect" -import { createIsMutedGetter } from "~/atoms/notification-sound-atoms" -import { channelCollection, messageCollection, notificationCollection, userCollection } from "~/db/collections" +import { Clock, Context, Effect, Layer, Option, Schema, Stream } from "effect" +import { + channelCollection, + messageCollection, + notificationCollection, + userCollection, +} from "~/db/collections" import { notificationSoundManager } from "~/lib/notification-sound-manager" import { - inAppNotificationSink, NativeNotificationSink, type NotificationEvent, notificationOrchestrator, SoundNotificationSink, } from "~/lib/notifications" -import type { SoundSettings } from "../notification-sound" +import { isSoundMuted, type SoundSettings } from "../notification-sound" /** - * The legacy `NotificationSoundProvider` (org layout) at the root: the singletons it wires (sound - * manager, orchestrator, sound and native sinks) and the recent notifications it feeds them. + * The legacy `NotificationSoundProvider` (org layout) at the root: the sinks it wires into the + * orchestrator, and the recent notifications it feeds them. */ -const soundSink = new SoundNotificationSink({ notificationSoundManager }) -const nativeSink = new NativeNotificationSink() +/** The sound and native sinks, made once for the app and handed to the orchestrator while wired. */ +export class NotificationSinks extends Context.Service()("NotificationSinks", { + make: Effect.sync(() => ({ + soundSink: new SoundNotificationSink({ notificationSoundManager }), + nativeSink: new NativeNotificationSink(), + })), +}) { + static readonly layer = Layer.effect(this, this.make) +} export interface SinkContext { readonly userId: UserId readonly settings: SoundSettings readonly currentChannelId: ChannelId | null - /** `sessionStartTimeAtom`: notifications from before this load never play. */ + /** Notifications from before this load never play. */ readonly sessionStartMs: number } @@ -37,42 +47,45 @@ export const wireNotificationSinks = ({ settings, currentChannelId, sessionStartMs, -}: SinkContext): Stream.Stream => - Stream.callback(() => +}: SinkContext): Stream.Stream => + Stream.callback(() => Effect.acquireRelease( - Effect.sync(() => { - const sessionStartTime = new Date(sessionStartMs) - const cleanupPriming = notificationSoundManager.initPriming() - notificationSoundManager.setDependencies({ - getConfig: () => ({ - soundFile: settings.soundFile, - volume: settings.volume, - cooldownMs: settings.cooldownMs, - }), - }) - notificationOrchestrator.setDependencies({ - getContext: () => { - const userSettings = userCollection.state.get(userId)?.settings - return { - currentChannelId, - sessionStartTime, - isMuted: createIsMutedGetter( - settings, - userSettings?.doNotDisturb ?? false, - userSettings?.quietHoursStart ?? "22:00", - userSettings?.quietHoursEnd ?? "08:00", - )(), - isWindowFocused: document.hasFocus(), - visibilityState: document.visibilityState, - now: Date.now(), - } - }, - inAppSink: inAppNotificationSink, - soundSink, - nativeSink, - }) - return cleanupPriming - }), + NotificationSinks.use(({ soundSink, nativeSink }) => + Effect.sync(() => { + const sessionStartTime = new Date(sessionStartMs) + const cleanupPriming = notificationSoundManager.initPriming() + notificationSoundManager.setDependencies({ + getConfig: () => ({ + soundFile: settings.soundFile, + volume: settings.volume, + cooldownMs: settings.cooldownMs, + }), + }) + notificationOrchestrator.setDependencies({ + getContext: () => { + const userSettings = userCollection.state.get(userId)?.settings + const now = Date.now() + return { + currentChannelId, + sessionStartTime, + isMuted: isSoundMuted({ + settings, + doNotDisturb: userSettings?.doNotDisturb ?? false, + quietHoursStart: userSettings?.quietHoursStart ?? "22:00", + quietHoursEnd: userSettings?.quietHoursEnd ?? "08:00", + nowMs: now, + }), + isWindowFocused: document.hasFocus(), + visibilityState: document.visibilityState, + now, + } + }, + soundSink, + nativeSink, + }) + return cleanupPriming + }), + ), (cleanupPriming) => Effect.sync(cleanupPriming), ).pipe(Effect.flatMap(() => Effect.never)), ) @@ -82,22 +95,26 @@ const decodeChannelId = Schema.decodeUnknownOption(ChannelId) /** The provider's `recentNotifications` effect: oldest first, with the rows the sinks describe. */ export const deliverNotifications = (ids: ReadonlyArray) => - Effect.sync(() => { - const events = ids.flatMap((id): ReadonlyArray => { - const notification = notificationCollection.state.get(id) - if (notification === undefined) return [] - const message = Option.getOrUndefined( - Option.flatMapNullishOr(decodeMessageId(notification.resourceId), (id) => - messageCollection.state.get(id), - ), - ) - const author = message?.authorId ? userCollection.state.get(message.authorId) : undefined - const channel = Option.getOrUndefined( - Option.flatMapNullishOr(decodeChannelId(notification.targetedResourceId), (id) => - channelCollection.state.get(id), - ), - ) - return [{ id: notification.id, notification, message, author, channel, receivedAt: Date.now() }] - }) - notificationOrchestrator.enqueue([...events].reverse()) - }) + Effect.flatMap(Clock.currentTimeMillis, (receivedAtMs) => + Effect.sync(() => { + const events = ids.flatMap((id): ReadonlyArray => { + const notification = notificationCollection.state.get(id) + if (notification === undefined) return [] + const message = Option.getOrUndefined( + Option.flatMapNullishOr(decodeMessageId(notification.resourceId), (id) => + messageCollection.state.get(id), + ), + ) + const author = message?.authorId ? userCollection.state.get(message.authorId) : undefined + const channel = Option.getOrUndefined( + Option.flatMapNullishOr(decodeChannelId(notification.targetedResourceId), (id) => + channelCollection.state.get(id), + ), + ) + return [ + { id: notification.id, notification, message, author, channel, receivedAt: receivedAtMs }, + ] + }) + notificationOrchestrator.enqueue([...events].reverse()) + }), + ) diff --git a/apps/web-foldkit/src/app/out-messages.story.test.ts b/apps/web-foldkit/src/app/out-messages.story.test.ts index d1407add1..3b690dd60 100644 --- a/apps/web-foldkit/src/app/out-messages.story.test.ts +++ b/apps/web-foldkit/src/app/out-messages.story.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import * as UiToast from "@foldkit/ui/toast" import { Option, Schema } from "effect" import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" @@ -7,6 +8,7 @@ import * as CommandPalette from "../overlay/command-palette" import { FocusInput, SetPresenceStatus } from "../overlay/command-palette/commands" import { LayoutHotkeyActionId } from "../overlay/hotkeys" import * as ChannelsSidebar from "../shell/channels-sidebar" +import { userMenuEntries } from "../shell/menus" import * as Shell from "../shell/model" import * as Platform from "../platform" import * as Presence from "../platform/presence/message" @@ -16,8 +18,7 @@ import { ada, plainMember, signedIn } from "../test/root-fixtures" import * as CommandMenu from "../ui/command-menu" import { DEFAULT_SOUND_SETTINGS } from "../notification-sound" import * as Menu from "../ui/menu" -import * as Toast from "../ui/toast" -import { ApplyTheme, NavigateInternal, SaveSoundSettings, SaveThemePreference, SignOut } from "./command" +import { ApplyTheme, LoadExternal, NavigateInternal, SaveSoundSettings, SaveThemePreference, SignOut } from "./command" import { Message } from "./message" /** The facts children report (OutMessages) and what the root does with them. */ @@ -54,7 +55,7 @@ describe("RequestedModal", () => { update, given(signedIn("/hazel/chat")), message(sidebar(ChannelsSidebar.Message.ClickedSectionAction({ action: "create-channel" }))), - model((m) => expect(m.modal?._tag).toBe("NewChannel")), + model((m) => expect(m.modal.current?._tag).toBe("NewChannel")), ) }) @@ -67,7 +68,7 @@ describe("RequestedModal", () => { message(paletteItem("action:invite")), model((m) => { expect(m.commandPalette.isOpen).toBe(false) - expect(m.modal?._tag).toBe("EmailInvite") + expect(m.modal.current?._tag).toBe("EmailInvite") }), ) }) @@ -115,16 +116,22 @@ describe("Completed (palette navigation)", () => { }) describe("RequestedSignOut", () => { - test("Log out in the user menu signs out through Clerk", () => { + test("Log out in the user menu signs out, then loads `/` afresh", () => { const userMenu = (inner: Menu.Message): Message => Message.GotShellMessage({ message: Shell.Message.GotUserMenuMessage({ message: inner }) }) story( update, given(signedIn("/hazel/chat")), message(userMenu(Menu.Message.PressedTrigger({ pointerType: "mouse" }))), - message(userMenu(Menu.Message.ClickedItem({ key: "logout" }))), + message( + userMenu( + Menu.Message.ClickedItem({ key: "logout", entries: userMenuEntries("hazel", ada.id) }), + ), + ), Command.expectExact(SignOut()), - Command.resolve(SignOut, Message.CompletedSignOut()), + Command.resolve(SignOut, Message.SucceededSignOut()), + Command.expectExact(LoadExternal({ href: "/" })), + Command.resolve(LoadExternal, Message.CompletedLoadExternal()), ) }) @@ -132,11 +139,10 @@ describe("RequestedSignOut", () => { story( update, given(signedIn("/hazel/chat")), - message(Message.FailedSignOut({ reason: "offline" })), - model((m) => expect(m.toasts.toaster.toasts.map((toast) => toast.title)).toEqual(["Failed to sign out"])), - Command.resolve(Toast.StartToastTimer, Toast.Message.StartedTimer({ id: 1, version: 1, at: 0 })), - Command.resolve(Toast.WaitForToastLifetime, Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 })), - Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), + message(Message.FailedSignOut({ reason: "Offline" })), + model((m) => expect(m.toasts.entries.map((entry) => entry.payload.title)).toEqual(["Failed to sign out"])), + UiToast.test.drainEntry({ entryId: "toaster-entry-0" }), + model((m) => expect(m.toasts.entries).toEqual([])), ) }) }) @@ -147,7 +153,11 @@ describe("RequestedMobileSidebar", () => { story( update, given(signedIn(`/hazel/chat/${channelId}`)), - message(Message.GotPageMessage({ message: { _tag: "ChatChannel", message: { _tag: "ClickedMobileMenu" } } })), + message( + Message.GotPageMessage({ + message: { _tag: "ChatChannel", message: { _tag: "ClickedMobileMenu" } }, + }), + ), Command.expectNone(), model((m) => expect(m.shell.isSidebarOpen).toBe(true)), ) @@ -162,7 +172,10 @@ describe("RequestedSoundSettings", () => { given(signedIn("/hazel/my-settings/notifications")), message( Message.GotPageMessage({ - message: { _tag: "MySettingsNotifications", message: { _tag: "ToggledSounds", isSelected: false } }, + message: { + _tag: "MySettingsNotifications", + message: { _tag: "ToggledSounds", isSelected: false }, + }, }), ), Command.expectHas(SaveSoundSettings({ settings: off })), @@ -179,13 +192,13 @@ describe("hotkeys", () => { given(signedIn("/hazel/chat", plainMember)), message(Message.PressedHotkey({ actionId: "channel.create" })), Command.expectNone(), - model((m) => expect(m.modal).toBeNull()), + model((m) => expect(m.modal.current).toBeNull()), ) story( update, given(signedIn("/hazel/chat")), message(Message.PressedHotkey({ actionId: "channel.create" })), - model((m) => expect(m.modal?._tag).toBe("NewChannel")), + model((m) => expect(m.modal.current?._tag).toBe("NewChannel")), ) }) @@ -217,17 +230,39 @@ describe("palette status and presence", () => { story( update, given(signedIn("/hazel/chat")), - message(presence(Presence.Message.ChangedContext({ userId: ada.id, activeChannelId: null, nowMs: 1 }))), + message( + presence( + Presence.Message.ChangedContext({ userId: ada.id, activeChannelId: null, nowMs: 1 }), + ), + ), message(presence(Presence.Message.ElapsedSyncDebounce({ version: 1 }))), - Command.resolve(SendPresenceUpdate, Presence.Message.SucceededSendPresenceUpdate()), + Command.resolve( + SendPresenceUpdate, + Presence.Message.SucceededSendPresenceUpdate({ + userId: ada.id, + sent: { status: "online", activeChannelId: null }, + }), + ), message(Message.PressedHotkey({ actionId: "commandPalette.open" })), Command.resolve(FocusInput, focused), message(paletteItem("status:dnd")), Command.resolve(SetPresenceStatus, succeeded), model((m) => expect(m.platform.presence.manualStatus).toBe("dnd")), message(presence(Presence.Message.ElapsedSyncDebounce({ version: 2 }))), - Command.expectExact(SendPresenceUpdate({ status: "dnd" })), - Command.resolve(SendPresenceUpdate, Presence.Message.SucceededSendPresenceUpdate()), + Command.expectExact( + SendPresenceUpdate({ + userId: ada.id, + snapshot: { status: "dnd", activeChannelId: null }, + status: "dnd", + }), + ), + Command.resolve( + SendPresenceUpdate, + Presence.Message.SucceededSendPresenceUpdate({ + userId: ada.id, + sent: { status: "dnd", activeChannelId: null }, + }), + ), message(presence(Presence.Message.ReachedAfkTimeout({ nowMs: 1 + AFK_TIMEOUT_MS }))), message(presence(Presence.Message.DetectedActivity({ atMs: 2 + AFK_TIMEOUT_MS }))), Command.resolve(BroadcastActivity, Presence.Message.CompletedBroadcastActivity()), diff --git a/apps/web-foldkit/src/app/routing.story.test.ts b/apps/web-foldkit/src/app/routing.story.test.ts index 38f598db0..4b2eb7542 100644 --- a/apps/web-foldkit/src/app/routing.story.test.ts +++ b/apps/web-foldkit/src/app/routing.story.test.ts @@ -1,9 +1,12 @@ // @vitest-environment jsdom +import { OrganizationId } from "@hazel/schema" +import { Schema } from "effect" import { Command, given, message, model, story } from "foldkit/story" import { UrlRequest } from "foldkit/navigation" import { describe, expect, test } from "vitest" -import { update } from "../main" -import { ada, after, boot, signedIn, urlOf } from "../test/root-fixtures" +import { MAX_AUTO_RETRIES, update } from "../main" +import { AppRoute } from "../route" +import { ada, after, boot, hazelOrg, owner, signedIn, urlOf } from "../test/root-fixtures" import { CheckSession, FetchCurrentUser, @@ -14,8 +17,9 @@ import { RetryFetchCurrentUser, } from "./command" import { Message } from "./message" +import { currentUserOf, sharedOf } from "./model" -/** Root routing: links, URL changes, the `beforeLoad` redirects and the signed-out gate. */ +/** Root routing: links, URL changes, the `beforeLoad` redirects, the signed-out gate and the session. */ describe("links", () => { test("an internal link pushes the URL and an external one loads the page", () => { @@ -31,11 +35,11 @@ describe("links", () => { ) }) - test("a section layout tab navigates like a link", () => { + test("a section layout tab navigates to its route", () => { story( update, given(signedIn("/hazel/settings/team")), - message(Message.ClickedLayoutTab({ href: "/hazel/settings/invitations" })), + message(Message.ClickedLayoutTab({ route: AppRoute.SettingsInvitations({ orgSlug: "hazel" }) })), Command.expectExact(NavigateInternal({ url: "/hazel/settings/invitations" })), Command.resolve(NavigateInternal, Message.CompletedNavigateInternal()), ) @@ -59,22 +63,20 @@ describe("redirects", () => { update, given(boot("/hazel/settings/team?tab=roles")), message(Message.ChangedAuth({ auth: "SignedOut" })), - Command.expectExact(ReplaceUrl({ url: "/sign-in?redirect_url=%2Fhazel%2Fsettings%2Fteam%3Ftab%3Droles" })), + Command.expectExact( + ReplaceUrl({ url: "/sign-in?redirect_url=%2Fhazel%2Fsettings%2Fteam%3Ftab%3Droles" }), + ), Command.resolve(ReplaceUrl, Message.CompletedReplaceUrl()), ) }) - test("a signed-out visitor on a public route stays, and the join page still asks for user.me", () => { + test("a signed-out visitor on a public route stays, with no user to ask for", () => { story( update, given(boot("/join/hazel")), message(Message.ChangedAuth({ auth: "SignedOut" })), - Command.expectExact(FetchCurrentUser()), - Command.resolve( - FetchCurrentUser, - Message.FailedFetchCurrentUser({ reason: "Unauthorized", isUnauthorized: true }), - ), - model((m) => expect(m.currentUser).toBeNull()), + Command.expectNone(), + model((m) => expect(currentUserOf(m)).toBeNull()), ) }) @@ -90,32 +92,69 @@ describe("redirects", () => { ) }) - test("a signed-in user.me failure asks again until it succeeds", () => { - const offline = Message.FailedFetchCurrentUser({ reason: "offline", isUnauthorized: false }) + // A5: only the ChannelSettingsRedirect page forwards; `routeRedirect` no longer doubles it. + test("the channel settings index forwards to its overview tab once", () => { + const channelId = "00000000-0000-4000-8000-0000000000c1" + const overview = ReplaceUrl({ url: `/hazel/channels/${channelId}/settings/overview` }) + story( + update, + given(signedIn("/hazel/settings/team")), + message(Message.ChangedUrl({ url: urlOf(`/hazel/channels/${channelId}/settings`) })), + Command.expectExact(overview), + model((m) => expect(m.route._tag).toBe("ChannelSettings")), + Command.resolve(ReplaceUrl, Message.CompletedReplaceUrl()), + ) + }) +}) + +// P0: an unreachable API at boot used to count as signed out and send the user to sign-in. +describe("boot session check", () => { + test("an unanswered check is Unreachable, never signed out, and retries a bounded number of times", () => { + const offline = Message.FailedCheckSession({ reason: "Offline" }) + const steps = Array.from({ length: MAX_AUTO_RETRIES }, () => [ + Command.expectExact(RetryCheckSession()), + Command.resolve(RetryCheckSession, offline), + ]).flat() story( update, given(boot("/hazel/settings/team")), - message(Message.ChangedAuth({ auth: "SignedIn" })), - Command.resolve(FetchCurrentUser, offline), - Command.expectExact(RetryFetchCurrentUser()), - Command.resolve(RetryFetchCurrentUser, offline), - Command.expectExact(RetryFetchCurrentUser()), - Command.resolve(RetryFetchCurrentUser, Message.SucceededFetchCurrentUser({ user: ada })), - model((m) => expect(m.currentUser).toEqual(ada)), + message(offline), + model((m) => { + expect(m.session).toEqual({ _tag: "Unreachable", reason: "Offline", retries: 1 }) + expect(sharedOf(m).auth).toBe("Loading") + }), + ...steps, + // Out of automatic retries: no sign-in redirect, no further Command; the error screen waits. Command.expectNone(), + model((m) => expect(m.session._tag).toBe("Unreachable")), ) }) - test("an unanswered session check keeps auth loading and asks again", () => { + test("Try again checks at once, and an answer clears the error", () => { story( update, given(boot("/hazel/settings/team")), - message(Message.FailedCheckSession()), - Command.expectExact(RetryCheckSession()), - model((m) => expect(m.auth).toBe("Loading")), - Command.resolve(RetryCheckSession, Message.ChangedAuth({ auth: "SignedIn" })), - model((m) => expect(m.isSessionStalled).toBe(false)), + message(Message.FailedCheckSession({ reason: "Unavailable" })), + Command.resolve(RetryCheckSession, Message.FailedCheckSession({ reason: "Unavailable" })), + Command.resolve(RetryCheckSession, Message.FailedCheckSession({ reason: "Unavailable" })), + Command.resolve(RetryCheckSession, Message.FailedCheckSession({ reason: "Unavailable" })), + message(Message.PressedRetrySession()), + model((m) => expect(m.session._tag).toBe("Loading")), + Command.expectExact(CheckSession()), + Command.resolve(CheckSession, Message.ChangedAuth({ auth: "SignedIn" })), Command.resolve(FetchCurrentUser, Message.SucceededFetchCurrentUser({ user: ada })), + model((m) => expect(currentUserOf(m)).toEqual(ada)), + ) + }) + + test("a failed re-check while signed in keeps the session", () => { + story( + update, + given(signedIn("/hazel/settings/team")), + message(Message.RejectedSession()), + Command.resolve(CheckSession, Message.FailedCheckSession({ reason: "Offline" })), + Command.expectNone(), + model((m) => expect(m.session._tag).toBe("SignedIn")), ) }) @@ -130,45 +169,90 @@ describe("redirects", () => { Command.resolve(ReplaceUrl, Message.CompletedReplaceUrl()), ) }) +}) - test("an unauthorized user.me while signed in re-reads the session", () => { +describe("user.me", () => { + test("a transient failure retries a bounded number of times, then waits for Try again", () => { + const offline = Message.FailedFetchCurrentUser({ reason: "Offline" }) + const steps = Array.from({ length: MAX_AUTO_RETRIES }, () => [ + Command.expectExact(RetryFetchCurrentUser()), + Command.resolve(RetryFetchCurrentUser, offline), + ]).flat() story( update, given(boot("/hazel/settings/team")), message(Message.ChangedAuth({ auth: "SignedIn" })), - Command.resolve( - FetchCurrentUser, - Message.FailedFetchCurrentUser({ reason: "Unauthorized", isUnauthorized: true }), - ), - Command.expectExact(CheckSession(), RetryFetchCurrentUser()), - Command.resolve(CheckSession, Message.ChangedAuth({ auth: "SignedOut" })), - Command.expectHas(ReplaceUrl({ url: "/sign-in?redirect_url=%2Fhazel%2Fsettings%2Fteam" })), - Command.resolveAll( - [ReplaceUrl, Message.CompletedReplaceUrl()], - [ - RetryFetchCurrentUser, - Message.FailedFetchCurrentUser({ reason: "Unauthorized", isUnauthorized: true }), - ], - ), + Command.resolve(FetchCurrentUser, offline), + ...steps, Command.expectNone(), + model((m) => + expect(m.session).toMatchObject({ + _tag: "SignedIn", + user: { _tag: "Failure", error: "Offline" }, + }), + ), + message(Message.PressedRetryCurrentUser()), + Command.expectExact(FetchCurrentUser()), + Command.resolve(FetchCurrentUser, Message.SucceededFetchCurrentUser({ user: ada })), + model((m) => expect(currentUserOf(m)).toEqual(ada)), ) }) - // A5: only the ChannelSettingsRedirect page forwards; `routeRedirect` no longer doubles it. - test("the channel settings index forwards to its overview tab once", () => { - const channelId = "00000000-0000-4000-8000-0000000000c1" - const overview = ReplaceUrl({ url: `/hazel/channels/${channelId}/settings/overview` }) + test("an ended session re-reads it instead of retrying, and goes to sign-in once it is gone", () => { story( update, - given(signedIn("/hazel/settings/team")), - message(Message.ChangedUrl({ url: urlOf(`/hazel/channels/${channelId}/settings`) })), - Command.expectExact(overview), - model((m) => expect(m.route._tag).toBe("ChannelSettings")), + given(boot("/hazel/settings/team")), + message(Message.ChangedAuth({ auth: "SignedIn" })), + Command.resolve(FetchCurrentUser, Message.FailedFetchCurrentUser({ reason: "SignedOut" })), + Command.expectExact(CheckSession()), + Command.resolve(CheckSession, Message.ChangedAuth({ auth: "SignedOut" })), + Command.expectHas(ReplaceUrl({ url: "/sign-in?redirect_url=%2Fhazel%2Fsettings%2Fteam" })), Command.resolve(ReplaceUrl, Message.CompletedReplaceUrl()), + Command.expectNone(), ) }) }) +// P0: after a switch, pages saw the previous organization and role until the new one loaded. +describe("organization switch", () => { + const acmeOrg = { + ...hazelOrg, + id: Schema.decodeSync(OrganizationId)("00000000-0000-4000-8000-0000000000a2"), + slug: "acme", + name: "Acme", + } + + test("the new slug starts loading: no previous organization or role is shared", () => { + const switched = after(signedIn("/hazel/settings/team"), [ + Message.ChangedUrl({ url: urlOf("/acme/settings/team") }), + ]) + expect(switched.org).toEqual({ _tag: "Loading", slug: "acme", isStalled: false }) + const shared = sharedOf(switched) + expect([shared.orgSlug, shared.organization, shared.member]).toEqual(["acme", null, null]) + }) + + test("a late answer for the previous slug, or its member row, is ignored", () => { + const switched = after(signedIn("/hazel/settings/team"), [ + Message.ChangedUrl({ url: urlOf("/acme/settings/team") }), + Message.UpdatedOrganization({ orgSlug: "hazel", organization: hazelOrg }), + Message.UpdatedMember({ organizationId: hazelOrg.id, member: owner }), + ]) + expect(switched.org._tag).toBe("Loading") + expect(sharedOf(switched).member).toBeNull() + }) + + test("the new organization arrives without the previous role, which waits for its own row", () => { + const loaded = after(signedIn("/hazel/settings/team"), [ + Message.ChangedUrl({ url: urlOf("/acme/settings/team") }), + Message.UpdatedOrganization({ orgSlug: "acme", organization: acmeOrg }), + ]) + expect(sharedOf(loaded).organization).toEqual(acmeOrg) + expect(sharedOf(loaded).member).toBeNull() + const withRole = after(loaded, [Message.UpdatedMember({ organizationId: acmeOrg.id, member: owner })]) + expect(sharedOf(withRole).member).toEqual(owner) + }) +}) + describe("page instances", () => { test("a URL change builds the new route's page and drops the previous one", () => { const model = after(signedIn("/hazel/settings/team"), [ @@ -176,7 +260,7 @@ describe("page instances", () => { ]) expect(model.route._tag).toBe("MySettingsDesktop") expect(model.page?._tag).not.toBe(signedIn("/hazel/settings/team").page?._tag) - expect(model.pathname).toBe("/hazel/my-settings/desktop") + expect(model.currentUrl).toBe("/hazel/my-settings/desktop") }) test("tabs of one page keep its instance (the page key ignores the tab)", () => { diff --git a/apps/web-foldkit/src/app/session.scene.test.ts b/apps/web-foldkit/src/app/session.scene.test.ts index 7d2e2f12f..72b107e95 100644 --- a/apps/web-foldkit/src/app/session.scene.test.ts +++ b/apps/web-foldkit/src/app/session.scene.test.ts @@ -31,16 +31,13 @@ describe("session states", () => { ) }) - test("a failed user.me keeps the loader (no error screen) and asks again", () => { + test("a failed user.me keeps the loader while it retries", () => { const [, user] = sessionMessages() scene( rootScene, given(boot("/hazel/settings/team")), Subscription.emit(Message.ChangedAuth({ auth: "SignedIn" })), - Command.resolve( - FetchCurrentUser, - Message.FailedFetchCurrentUser({ reason: "offline", isUnauthorized: false }), - ), + Command.resolve(FetchCurrentUser, Message.FailedFetchCurrentUser({ reason: "Offline" })), sceneExpect(loader).toExist(), Command.resolve(RetryFetchCurrentUser, user), sceneExpect(loader).toExist(), diff --git a/apps/web-foldkit/src/app/shell.scene.test.ts b/apps/web-foldkit/src/app/shell.scene.test.ts index d3db9b17d..35c068c1f 100644 --- a/apps/web-foldkit/src/app/shell.scene.test.ts +++ b/apps/web-foldkit/src/app/shell.scene.test.ts @@ -21,14 +21,13 @@ import { import { describe, test } from "vitest" import * as CommandPalette from "../overlay/command-palette" import { CreateChannel, FocusInput } from "../overlay/command-palette/commands" -import * as Shell from "../shell/model" import { ada, hazelOrg, rootScene, signedIn } from "../test/root-fixtures" +import { drainToast } from "../test/toast-fixtures" import * as CommandMenu from "../ui/command-menu" import { PortalCommandMenu } from "../ui/command-menu-view" import * as Menu from "../ui/menu" import { FocusTriggerOnPress } from "../ui/menu-view" import * as Modal from "../ui/modal" -import * as Toast from "../ui/toast" import { NavigateInternal } from "./command" import { Message } from "./message" @@ -59,7 +58,7 @@ describe("nav rail", () => { }) describe("mobile", () => { - const mobile = Message.GotShellMessage({ message: Shell.Message.ChangedViewport({ isMobile: true }) }) + const mobile = Message.ChangedViewport({ isMobile: true }) test("the bottom nav's Menu button opens the sidebar sheet", () => { scene( @@ -73,7 +72,7 @@ describe("mobile", () => { sceneExpect(role("dialog", { name: "Sidebar" })).toBeAbsent(), click(role("button", { name: /Menu/ })), sceneExpect(role("dialog", { name: "Sidebar" })).toExist(), - Mount.resolve(Modal.PortalModal, Modal.Message.CompletedPortalModal()), + Mount.resolve(Modal.AcquireResources, Modal.Message.SucceededAcquireResources()), ...menusMounted, sceneExpect(within(role("dialog", { name: "Sidebar" }), navItem("Settings"))).toExist(), ) @@ -125,23 +124,14 @@ describe("command palette", () => { }), ), Command.resolve(CreateChannel, CommandPalette.Message.SucceededCreateChannel({ channelId })), - Command.expectExact( - NavigateInternal({ url: `/hazel/chat/${channelId}` }), - Toast.StartToastTimer({ id: 1, version: 1 }), - ), - Command.resolveAll( - [NavigateInternal, Message.CompletedNavigateInternal()], - [Toast.StartToastTimer, Toast.Message.StartedTimer({ id: 1, version: 1, at: 0 })], - ), + Command.expectHas(NavigateInternal({ url: `/hazel/chat/${channelId}` })), + Command.resolve(NavigateInternal, Message.CompletedNavigateInternal()), sceneExpect(palette).toBeAbsent(), Mount.expectEnded(PortalCommandMenu), sceneExpect(text("Channel created successfully")).toExist(), - Mount.resolve(Toast.MeasureToast, Toast.Message.MeasuredToast({ id: 1, height: 52 })), - // The toast's lifetime runs out (sonner's 4s), then it is removed after its exit animation. - Command.resolve(Toast.WaitForToastLifetime, Toast.Message.CompletedWaitForLifetime({ id: 1, version: 1 })), - Command.resolve(Toast.WaitForToastRemoval, Toast.Message.CompletedWaitForRemoval({ id: 1 })), + // The toast enters, its 4s lifetime runs out, then it is removed after its exit animation. + ...drainToast(), sceneExpect(text("Channel created successfully")).toBeAbsent(), - Mount.expectEnded(Toast.MeasureToast), ) }) }) diff --git a/apps/web-foldkit/src/app/subscription.ts b/apps/web-foldkit/src/app/subscription.ts index 4eafe8a7d..7d42c06d1 100644 --- a/apps/web-foldkit/src/app/subscription.ts +++ b/apps/web-foldkit/src/app/subscription.ts @@ -1,7 +1,7 @@ import { ChannelId, type NotificationId, OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { eq } from "@tanstack/db" -import { Duration, Option, Schema, Stream } from "effect" -import { Dom, ManagedResource, Subscription } from "foldkit" +import { Clock, Duration, Option, Schema, Stream } from "effect" +import { Dom, Subscription } from "foldkit" import { notificationCollection, organizationCollection, @@ -11,8 +11,8 @@ import { SESSION_REJECTED_EVENT } from "~/lib/access-token" import { liveQueryStream } from "../data/live-query" import { pageSubscriptions } from "../page/registry" import { SoundSettings } from "../notification-sound" -import { wireNotificationSinks } from "./notification-sinks" -import { type AppRoute, orgSlugOf } from "../route" +import { type NotificationSinks, wireNotificationSinks } from "./notification-sinks" +import { chatChannelIdOf, orgSlugOf } from "../route" import type { Member, Organization } from "../session" import * as ShellSubscription from "../shell/subscription" import * as CommandPalette from "../overlay/command-palette" @@ -23,7 +23,17 @@ import { desktopMenuItems } from "../platform/desktop-menu" import * as Updates from "../platform/updates" import * as Toasts from "../overlay/toaster" import { Message } from "./message" -import { type Model, pageHostOf, sharedOf } from "./model" +import { authOf, currentUserOf, loadedOrgOf, memberOf, type Model, pageHostOf, sharedOf } from "./model" + +/** `useSidebar().isMobile`: the viewport the mobile layout is for. */ +export const MOBILE_QUERY = "(max-width: 767px)" + +/** How often the root clock ticks (legacy `presenceNowSignal` and the sidebar's presence dots). */ +const CLOCK_INTERVAL = Duration.seconds(30) + +/** The org shell is up: the session, its user and the route's organization have loaded. */ +const isShellReady = (model: Model): boolean => + currentUserOf(model) !== null && Option.isSome(loadedOrgOf(model)) const rootSubscriptions = Subscription.make()((entry) => ({ systemTheme: Subscription.persistentEntry( @@ -32,17 +42,32 @@ const rootSubscriptions = Subscription.make()((entry) => ({ mapMatches: (isDark) => Message.ChangedSystemTheme({ theme: isDark ? "dark" : "light" }), }), ), - // Legacy `presenceNowSignal`: wall clock for deriving stale presence. - presenceClock: Subscription.persistentEntry( - Stream.concat(Stream.succeed(undefined), Stream.tick("30 seconds")).pipe( - Stream.map(() => Message.TickedPresenceClock({ nowMs: Date.now() })), - ), + // `useMediaQuery("(max-width: 767px)")` in `SidebarProvider`. + viewport: Subscription.persistentEntry( + Dom.streamFromMediaQuery({ + query: MOBILE_QUERY, + mapMatches: (isMobile) => Message.ChangedViewport({ isMobile }), + }), + ), + // The one wall clock (presence staleness, status expiry), ticking only while the org shell shows. + clock: entry( + { isTicking: Schema.Boolean }, + { + modelToDependencies: (model) => ({ isTicking: isShellReady(model) }), + dependenciesToStream: ({ isTicking }) => + isTicking + ? Stream.concat(Stream.succeed(undefined), Stream.tick(CLOCK_INTERVAL)).pipe( + Stream.mapEffect(() => Clock.currentTimeMillis), + Stream.map((nowMs) => Message.TickedClock({ nowMs })), + ) + : Stream.empty, + }, ), // Every shape retries a refused token at once; one session check covers them all. sessionRejected: entry( { isSignedIn: Schema.Boolean }, { - modelToDependencies: (model) => ({ isSignedIn: model.auth === "SignedIn" }), + modelToDependencies: (model) => ({ isSignedIn: authOf(model.session) === "SignedIn" }), dependenciesToStream: ({ isSignedIn }) => isSignedIn ? Stream.fromEventListener(window, SESSION_REJECTED_EVENT).pipe( @@ -95,8 +120,10 @@ const rootSubscriptions = Subscription.make()((entry) => ({ { organizationId: Schema.NullOr(OrganizationId), userId: Schema.NullOr(UserId) }, { modelToDependencies: (model) => ({ - organizationId: model.organization?.id ?? null, - userId: model.currentUser?.id ?? null, + organizationId: Option.getOrNull( + Option.map(loadedOrgOf(model), (org) => org.organization.id), + ), + userId: currentUserOf(model)?.id ?? null, }), dependenciesToStream: ({ organizationId, userId }) => organizationId === null || userId === null @@ -111,6 +138,7 @@ const rootSubscriptions = Subscription.make()((entry) => ({ (rows) => { const member = rows[0] return Message.UpdatedMember({ + organizationId, member: member ? { id: member.id, role: member.role } : null, }) }, @@ -119,14 +147,10 @@ const rootSubscriptions = Subscription.make()((entry) => ({ ), })) -/** `currentChannelIdAtom`: the channel the chat routes show. */ -const currentChannelIdOf = (route: AppRoute): ChannelId | null => - route._tag.startsWith("Chat") && "channelId" in route ? route.channelId : null - const MAX_RECENT_NOTIFICATIONS = 250 /** `NotificationSoundProvider` in `$orgSlug/layout.tsx`: sound and native notification sinks. */ -const notificationSubscriptions = Subscription.make()((entry) => ({ +const notificationSubscriptions = Subscription.make()((entry) => ({ notificationSinks: entry( { userId: Schema.NullOr(UserId), @@ -136,9 +160,9 @@ const notificationSubscriptions = Subscription.make()((entry) => }, { modelToDependencies: (model) => ({ - userId: orgSlugOf(model.route) === undefined ? null : (model.currentUser?.id ?? null), + userId: orgSlugOf(model.route) === undefined ? null : (currentUserOf(model)?.id ?? null), settings: model.soundSettings, - currentChannelId: currentChannelIdOf(model.route), + currentChannelId: chatChannelIdOf(model.route), sessionStartMs: model.sessionStartMs, }), dependenciesToStream: ({ userId, settings, currentChannelId, sessionStartMs }) => @@ -151,7 +175,7 @@ const notificationSubscriptions = Subscription.make()((entry) => recentNotifications: entry( { memberId: Schema.NullOr(OrganizationMemberId) }, { - modelToDependencies: (model) => ({ memberId: model.member?.id ?? null }), + modelToDependencies: (model) => ({ memberId: memberOf(model)?.id ?? null }), dependenciesToStream: ({ memberId }) => memberId === null ? Stream.empty @@ -174,7 +198,7 @@ const overlaySubscriptions = Subscription.make()((entry) => ({ { isEnabled: Schema.Boolean }, { modelToDependencies: (model) => ({ - isEnabled: model.auth === "SignedIn" && orgSlugOf(model.route) !== undefined, + isEnabled: authOf(model.session) === "SignedIn" && orgSlugOf(model.route) !== undefined, }), dependenciesToStream: ({ isEnabled }) => isEnabled ? layoutHotkeys((actionId) => Message.PressedHotkey({ actionId })) : Stream.empty, @@ -199,16 +223,16 @@ const shellSubscriptions = Subscription.lift(ShellSubscription.subscriptions) { - const orgSlug = orgSlugOf(model.route) - const isLoaded = - orgSlug !== undefined && model.orgLoad?.slug === orgSlug && model.orgLoad.status === "Found" - return isLoaded ? (model.currentUser?.id ?? null) : null -} +const presenceUserIdOf = (model: Model): UserId | null => + isShellReady(model) ? (currentUserOf(model)?.id ?? null) : null const platformSubscriptions = Subscription.lift(Platform.subscriptions)({ read: (model) => - Option.some({ model: model.platform, userId: presenceUserIdOf(model), pathname: model.pathname }), + Option.some({ + model: model.platform, + userId: presenceUserIdOf(model), + activeChannelId: chatChannelIdOf(model.route), + }), toParentMessage: (message): Message => ({ _tag: "GotPlatformMessage", message }), }) @@ -224,7 +248,7 @@ const desktopMenuSubscriptions = Subscription.make()(() => ({ })) const toastSubscriptions = Subscription.lift(Toasts.subscriptions)({ - read: (model) => Option.some(model.toasts.toaster), + read: (model) => Option.some(model.toasts), toParentMessage: (message): Message => ({ _tag: "GotToastsMessage", message }), }) @@ -246,9 +270,3 @@ export const subscriptions = Subscription.aggregate( updateSubscriptions, desktopMenuSubscriptions, ) - -/** App-lifetime managed resources (the Rivet client). */ -export const managedResources = ManagedResource.lift(Platform.managedResources)({ - read: (model) => Option.some(model.platform), - toParentMessage: (message): Message => ({ _tag: "GotPlatformMessage", message }), -}) diff --git a/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index f217371dc..8a633ec47 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -1,29 +1,39 @@ +import { AsyncData } from "foldkit" import type { Document, Html, HtmlBuilder } from "foldkit/html" +import { commonMessage } from "~/lib/error-messages" import * as CommandPalette from "../overlay/command-palette" import * as Modal from "../overlay/modal" import * as Toasts from "../overlay/toaster" import { type PageMessage, viewPage } from "../page/registry" -import { type AppRoute, isPublicRoute, orgSectionOf, orgSlugOf } from "../route" -import { displayNameOf } from "../session" +import { + channelSettingsTabOf, + chatChannelIdOf, + hasContentColumn, + isPublicRoute, + orgSectionOf, + orgSlugOf, + titleOf, +} from "../route" +import { type CurrentUser, displayNameOf, type Organization } from "../session" import { orgShell } from "../shell/app-shell" import * as ChannelsSidebar from "../shell/channels-sidebar" -import { formatStatusExpirationAt } from "../shell/menus" import type { ShellContext } from "../shell/context" import { + appError, appLoader, - organizationNotFound, - type ChannelSettingsTab, channelSettingsLayout, - contentColumnLayout, + organizationNotFound, sectionLayout, + contentColumnLayout, } from "../shell/layouts" +import { formatStatusExpirationAt } from "../shell/menus" import * as Shell from "../shell/model" import * as Notifications from "../shell/notifications" import { mySettingsSidebar, notificationsSidebar, settingsSidebar } from "../shell/sidebars" import type * as Menu from "../ui/menu" import type * as ModalKit from "../ui/modal" import { Message } from "./message" -import { type Model, resolvedThemeOf, sharedOf } from "./model" +import { type Model, OrgState, Session, sharedOf } from "./model" // Module-level and built as literals: memoized shell views compare them by reference, and // wrapper constructors would re-validate large payloads (sidebar rows, message pages) per dispatch. @@ -43,24 +53,29 @@ const openedMobileSidebar = toShellMessage(Shell.Message.ToggledSidebar({ isOpen export const toPageMessage = (message: PageMessage): Message => ({ _tag: "GotPageMessage", message }) const toToastsMessage = (message: Toasts.Message): Message => ({ _tag: "GotToastsMessage", message }) const toaster = (h: HtmlBuilder, model: Model): Html => - Toasts.view(h, model.toasts, resolvedThemeOf(model), toToastsMessage) + Toasts.view(h, model.toasts, toToastsMessage) const toModalMessage = (message: Modal.Message): Message => ({ _tag: "GotModalMessage", message }) const toCommandPaletteMessage = (message: CommandPalette.Message): Message => ({ _tag: "GotCommandPaletteMessage", message, }) -const shellContextOf = (model: Model, orgSlug: string): ShellContext => ({ - orgSlug, - pathname: model.pathname, - organization: model.organization ?? undefined, - currentUser: model.currentUser - ? { - displayName: displayNameOf(model.currentUser), - email: model.currentUser.email, - avatarUrl: model.currentUser.avatarUrl, - } - : undefined, +/** What the org shell shows: the loaded user and organization of `orgSlug`. */ +interface ShellData { + readonly orgSlug: string + readonly user: CurrentUser + readonly organization: Organization +} + +const shellContextOf = (model: Model, data: ShellData): ShellContext => ({ + orgSlug: data.orgSlug, + route: model.route, + organization: data.organization, + currentUser: { + displayName: displayNameOf(data.user), + email: data.user.email, + avatarUrl: data.user.avatarUrl, + }, appVersion: __APP_VERSION__, }) @@ -71,21 +86,9 @@ const pageBody = (model: Model, h: HtmlBuilder, isInsideMain: boolean): return isInsideMain ? h.div([placeholder]) : h.main([placeholder]) } -const channelSettingsTabOf = (route: AppRoute): ChannelSettingsTab | undefined => - route._tag === "ChannelSettingsOverview" - ? "overview" - : route._tag === "ChannelSettingsIntegrations" - ? "integrations" - : route._tag === "ChannelSettingsConnect" - ? "connect" - : undefined - -const hasContentColumn = (route: AppRoute) => - route._tag.startsWith("SettingsIntegration") || route._tag.startsWith("SettingsChatSync") - /** The route's section layout around the page (`settings/layout.tsx` and friends). */ const sectionBody = (model: Model, h: HtmlBuilder): Html => { - const route = model.route + const { route } = model const section = orgSectionOf(route) if (section === "Settings") return sectionLayout( @@ -105,8 +108,9 @@ const sectionBody = (model: Model, h: HtmlBuilder): Html => { channelId: route.channelId, channel: model.shell.settingsChannel, selectedTab: tab, - onSelectTab: (href) => h.OnClick(Message.ClickedLayoutTab({ href })), - onChangeTab: (toHref) => h.OnChange((tab) => Message.ClickedLayoutTab({ href: toHref(tab) })), + onSelectTab: (tabRoute) => h.OnClick(Message.ClickedLayoutTab({ route: tabRoute })), + onChangeTab: (toRoute) => + h.OnChange((value) => Message.ClickedLayoutTab({ route: toRoute(value) })), }, pageBody(model, h, true), ) @@ -118,6 +122,8 @@ const secondarySidebar = (model: Model, h: HtmlBuilder, context: ShellC const chrome = { userMenu: shell.userMenu, orgSwitcher: shell.orgSwitcher, + userMenuEntries: shell.userMenuEntries, + orgSwitcherEntries: shell.orgSwitcherEntries, organizations: shell.userOrganizations, userStatus: shell.userStatus, statusExpiration: formatStatusExpirationAt(shell.userStatus?.expiresAtMs ?? null, model.nowMs), @@ -133,16 +139,10 @@ const secondarySidebar = (model: Model, h: HtmlBuilder, context: ShellC isPending: Notifications.isMarkingAllRead(shell.notifications), onPress: h.OnClick(clickedMarkAllRead), }) - const route = model.route return ChannelsSidebar.view( h, shell.channelsSidebar, - { - shell: context, - activeChannelId: - route._tag.startsWith("Chat") && "channelId" in route ? route.channelId : undefined, - chrome, - }, + { shell: context, activeChannelId: chatChannelIdOf(model.route), nowMs: model.nowMs, chrome }, toChannelsSidebarMessage, ) } @@ -154,22 +154,8 @@ const loader = (h: HtmlBuilder, isStalled: boolean): Html => const appRoot = (h: HtmlBuilder, model: Model, children: ReadonlyArray): Html => h.div([h.Id("app")], [toaster(h, model), ...children]) -const body = (model: Model, h: HtmlBuilder): Html => { - const route = model.route - if (route._tag === "NotFound") return h.div([h.Id("app")], [`Not found: ${route.path}`]) - if (isPublicRoute(route)) return appRoot(h, model, [pageBody(model, h, false)]) - // `_app/layout.tsx`: a loader until the API answers for the session; signed out redirects to sign-in. - if (model.auth !== "SignedIn") return appRoot(h, model, [loader(h, model.isSessionStalled)]) - // `AppShell`: a loader until `user.me` answers. - if (model.currentUser === null) return appRoot(h, model, [loader(h, model.isCurrentUserStalled)]) - const orgSlug = orgSlugOf(route) - if (orgSlug === undefined) return appRoot(h, model, [pageBody(model, h, false)]) - // `$orgSlug/layout.tsx`: a loader while `useOrganization()` loads the route's organization. - const orgLoad = model.orgLoad?.slug === orgSlug ? model.orgLoad.status : "Loading" - if (orgLoad === "Loading" || orgLoad === "Stalled") - return appRoot(h, model, [loader(h, orgLoad === "Stalled")]) - if (orgLoad === "NotFound") return appRoot(h, model, [organizationNotFound(h, orgSlug)]) - const context = shellContextOf(model, orgSlug) +const orgShellView = (model: Model, h: HtmlBuilder, data: ShellData): Html => { + const context = shellContextOf(model, data) return orgShell(h, context, { page: sectionBody(model, h), secondarySidebar: secondarySidebar(model, h, context), @@ -187,7 +173,7 @@ const body = (model: Model, h: HtmlBuilder): Html => { }), ], mobile: { - isMobile: model.shell.isMobile, + isMobile: model.isMobile, isSidebarOpen: model.shell.isSidebarOpen, onMenu: h.OnClick(openedMobileSidebar), toSheetMessage: toMobileSidebarMessage, @@ -195,7 +181,52 @@ const body = (model: Model, h: HtmlBuilder): Html => { }) } +/** `$orgSlug/layout.tsx`: a loader while `useOrganization()` loads the route's organization. */ +const orgBody = (model: Model, h: HtmlBuilder, user: CurrentUser): Html => { + const orgSlug = orgSlugOf(model.route) + if (orgSlug === undefined) return appRoot(h, model, [pageBody(model, h, false)]) + return OrgState.match(model.org, { + None: () => appRoot(h, model, [loader(h, false)]), + Loading: ({ isStalled }) => appRoot(h, model, [loader(h, isStalled)]), + NotFound: () => appRoot(h, model, [organizationNotFound(h, orgSlug)]), + Loaded: ({ organization }) => orgShellView(model, h, { orgSlug, user, organization }), + }) +} + +const body = (model: Model, h: HtmlBuilder): Html => { + const { route } = model + if (route._tag === "NotFound") return h.div([h.Id("app")], [`Not found: ${route.path}`]) + if (isPublicRoute(route)) return appRoot(h, model, [pageBody(model, h, false)]) + // `_app/layout.tsx`: a loader until the API answers for the session; signed out redirects to sign-in. + return Session.match(model.session, { + Loading: () => appRoot(h, model, [loader(h, false)]), + SignedOut: () => appRoot(h, model, [loader(h, false)]), + Unreachable: ({ reason }) => + appRoot(h, model, [ + appError(h, { + title: "Can't reach Hazel", + description: commonMessage(reason).description, + onRetry: Message.PressedRetrySession(), + }), + ]), + // `AppShell`: a loader until `user.me` answers, then the error if it could not. + SignedIn: ({ user }) => + AsyncData.matchData(user, { + onEmpty: () => appRoot(h, model, [loader(h, false)]), + onFailure: (reason) => + appRoot(h, model, [ + appError(h, { + title: "Couldn't load your account", + description: commonMessage(reason).description, + onRetry: Message.PressedRetryCurrentUser(), + }), + ]), + onData: (data) => orgBody(model, h, data), + }), + }) +} + export const view = (model: Model, h: HtmlBuilder): Document => ({ - title: "Hazel Chat", + title: titleOf(model.route), body: body(model, h), }) diff --git a/apps/web-foldkit/src/auth/desktop-handoff.ts b/apps/web-foldkit/src/auth/desktop-handoff.ts index db315cc53..0e0d4286f 100644 --- a/apps/web-foldkit/src/auth/desktop-handoff.ts +++ b/apps/web-foldkit/src/auth/desktop-handoff.ts @@ -1,8 +1,10 @@ import { desktopLogin } from "@hazel/auth/contract" import { OperationHttpClient } from "@yielded/auth" -import { Effect, Layer, Redacted, Schema } from "effect" +import { type Context, Effect, Layer, Match, Redacted, Schema } from "effect" import { FetchHttpClient } from "effect/http" +import { CommonReason } from "~/lib/error-messages" import { HazelAuthClient } from "./client" +import { operationHttpReason } from "./flows" /** * The browser half of desktop sign-in: describe the desktop app's attempt, then authorize it @@ -20,42 +22,78 @@ const TransportLive = OperationHttpClient.layer({ Layer.orDie, ) +/** + * Why the handoff failed, beyond the shared reasons. `Expired`: the attempt is gone or used, so + * the desktop app has to start again. `NeedsSignIn`: this browser has no session. + */ +export const HandoffFailure = Schema.Union([Schema.Literals(["Expired", "NeedsSignIn"]), CommonReason]) +export type HandoffFailure = typeof HandoffFailure.Type + export class HandoffError extends Schema.TaggedError()("HandoffError", { - message: Schema.String, + reason: HandoffFailure, }) {} -const failed = (error: { readonly _tag: string }) => - new HandoffError({ - message: - error._tag === "OperationHttpError" - ? "Couldn't reach Hazel. Check your connection and try again." - : "This sign-in request has expired or was already used. Start again from the desktop app.", +type Transport = Context.Service.Shape +type AuthApi = Context.Service.Shape["auth"] +type SessionId = NonNullable>>["sessionId"] + +type HandoffCallError = + | Effect.Error> + | Effect.Error> + | Effect.Error> + +const callDescribe = (transport: Transport, attemptId: string) => + transport.call(desktopLogin.routes.describe, { attemptId }) + +const callAuthorize = (transport: Transport, attemptId: string, expectedSessionId: SessionId) => + transport.call(desktopLogin.routes.authorize, { attemptId, decision: "continue", expectedSessionId }) + +const expired = (): HandoffFailure => "Expired" +const unavailable = (): HandoffFailure => "Unavailable" +const unexpected = (): HandoffFailure => "Unexpected" + +/** Exhaustive: a new error in the handoff contract must be classified here. */ +const reasonOf = (error: HandoffCallError): HandoffFailure => + Match.valueTags(error, { + OperationHttpError: operationHttpReason, + BrowserLoginInvalid: expired, + BrowserLoginIndeterminate: expired, + PendingAuthenticationInvalid: expired, + StaleAuthentication: expired, + SessionInvalid: (): HandoffFailure => "NeedsSignIn", + AuthenticationRequired: (): HandoffFailure => "NeedsSignIn", + AssuranceRequired: (): HandoffFailure => "NeedsSignIn", + SessionStepUpInvalid: (): HandoffFailure => "NeedsSignIn", + BrowserLoginUnavailable: unavailable, + SessionUnavailable: unavailable, + SessionSignOutUnavailable: unavailable, + SessionCapabilityUnsupported: unavailable, + HookDenied: unexpected, + InvalidOperationInput: unexpected, + OperationForbidden: unexpected, + OperationPrivateOutputUnsupported: unexpected, + SessionConflict: unexpected, + SessionRenewalTooEarly: unexpected, }) +const failed = (error: HandoffCallError) => new HandoffError({ reason: reasonOf(error) }) + /** The app asking to sign in (`displayName`), or a failure if the attempt is gone. */ export const describeAttempt = (attemptId: string) => Effect.gen(function* () { const transport = yield* OperationHttpClient.Client - return yield* transport.call(desktopLogin.routes.describe, { attemptId }) - }).pipe(Effect.mapError(failed), Effect.provide(TransportLive)) + return yield* callDescribe(transport, attemptId).pipe(Effect.mapError(failed)) + }).pipe(Effect.provide(TransportLive)) /** Authorizes the attempt for this browser's session and returns the app's callback URL. */ export const authorizeAttempt = (attemptId: string) => Effect.gen(function* () { const client = yield* HazelAuthClient - const session = yield* client.auth.getSession() - if (session === null) return yield* new HandoffError({ message: "Sign in first, then continue." }) + const session = yield* client.auth.getSession().pipe(Effect.mapError(failed)) + if (session === null) return yield* new HandoffError({ reason: "NeedsSignIn" }) const transport = yield* OperationHttpClient.Client - const result = yield* transport.call(desktopLogin.routes.authorize, { - attemptId, - decision: "continue", - expectedSessionId: session.sessionId, - }) + const result = yield* callAuthorize(transport, attemptId, session.sessionId).pipe( + Effect.mapError(failed), + ) return Redacted.value(result.callbackUrl) - }).pipe( - Effect.catchIf( - (error) => error._tag !== "HandoffError", - (error) => Effect.fail(failed(error)), - ), - Effect.provide(TransportLive), - ) + }).pipe(Effect.provide(TransportLive)) diff --git a/apps/web-foldkit/src/auth/flows.test.ts b/apps/web-foldkit/src/auth/flows.test.ts new file mode 100644 index 000000000..53406077f --- /dev/null +++ b/apps/web-foldkit/src/auth/flows.test.ts @@ -0,0 +1,33 @@ +import { OperationHttpError } from "@yielded/auth/OperationHttp" +import { + PasskeyBrowserBusy, + PasskeyBrowserInputRejected, + PasskeyBrowserNotCompleted, + PasskeyBrowserUnsupported, +} from "@yielded/auth-simplewebauthn/Browser" +import { describe, expect, it } from "vitest" +import { authFailureOf } from "./flows" + +/** Auth failures are classified by their declared tag, never by the shape of the tag's name. */ +describe("authFailureOf", () => { + it("a closed or busy passkey prompt is a quiet cancel", () => { + expect(authFailureOf(new PasskeyBrowserNotCompleted())).toBe("Cancelled") + expect(authFailureOf(new PasskeyBrowserBusy())).toBe("Cancelled") + }) + + // It ends in "Rejected", which the old suffix match read as an expired sign-in. + it("a browser refusing the passkey options is unexpected, not an expired sign-in", () => { + expect(authFailureOf(new PasskeyBrowserInputRejected())).toBe("Unexpected") + }) + + it("a browser without passkeys says so", () => { + expect(authFailureOf(new PasskeyBrowserUnsupported())).toBe("PasskeyUnsupported") + }) + + it("the transport's reason picks offline, timeout or unavailable", () => { + expect(authFailureOf(new OperationHttpError({ reason: "network" }))).toBe("Offline") + expect(authFailureOf(new OperationHttpError({ reason: "timeout" }))).toBe("Timeout") + expect(authFailureOf(new OperationHttpError({ reason: "unavailable" }))).toBe("Unavailable") + expect(authFailureOf(new OperationHttpError({ reason: "csrf" }))).toBe("Unexpected") + }) +}) diff --git a/apps/web-foldkit/src/auth/flows.ts b/apps/web-foldkit/src/auth/flows.ts index d6dfc6a74..c6f7d05e9 100644 --- a/apps/web-foldkit/src/auth/flows.ts +++ b/apps/web-foldkit/src/auth/flows.ts @@ -1,6 +1,8 @@ +import type { OperationHttpError } from "@yielded/auth/OperationHttp" import * as PasskeyBrowser from "@yielded/auth-simplewebauthn/Browser" -import { Effect, Redacted, Schema } from "effect" +import { type Context, Effect, Match, Redacted, Schema } from "effect" import { load } from "foldkit/navigation" +import { CommonReason } from "~/lib/error-messages" import { HazelAuthClient, type Provider } from "./client" import { rememberReturnTo, takeReturnTo } from "./return-to" @@ -9,25 +11,108 @@ import { rememberReturnTo, takeReturnTo } from "./return-to" * registration (both end with a session cookie, then a full load so the app starts signed in). */ -/** A flow that did not finish. `message` is `null` when the user cancelled it themselves. */ +/** + * Why a flow did not finish, beyond the shared reasons. `Cancelled` is the visitor closing the + * passkey prompt themselves, so it shows nothing. + */ +export const AuthFailure = Schema.Union([ + Schema.Literals([ + "Cancelled", + "PasskeyUnsupported", + "Rejected", + "Expired", + "Denied", + "AccountConflict", + "AccountCreated", + ]), + CommonReason, +]) +export type AuthFailure = typeof AuthFailure.Type + export class AuthFlowError extends Schema.TaggedError()("AuthFlowError", { - message: Schema.NullOr(Schema.String), + reason: AuthFailure, }) {} -/** User-facing text for a failed auth call, by the failure's tag. */ -export const describeFailure = (tag: string): string | null => { - if (tag === "PasskeyBrowserNotCompleted" || tag === "PasskeyBrowserBusy") return null - if (tag === "PasskeyBrowserUnsupported" || tag === "PasskeyBrowserUnavailable") - return "This browser can't use passkeys. Continue with GitHub or Google instead." - if (tag === "OperationHttpError") return "Couldn't reach Hazel. Check your connection and try again." - if (tag.endsWith("Rejected") || tag.endsWith("ActionRequired")) - return "That sign-in expired or was already used. Please start again." - if (tag.endsWith("Unavailable")) return "Sign-in is unavailable right now. Please try again in a moment." - return "Something went wrong signing you in. Please try again." -} +type AuthApi = Context.Service.Shape["auth"] + +/** Everything the auth client and the browser's passkey prompt can fail with. */ +type AuthError = + | Effect.Error> + | Effect.Error> + | Effect.Error> + | Effect.Error> + | PasskeyBrowser.PasskeyBrowserFailure + +/** The transport's own reasons: offline and timeouts are worth another try, the rest is a bug. */ +export const operationHttpReason = (error: OperationHttpError): CommonReason => + error.reason === "network" + ? "Offline" + : error.reason === "timeout" + ? "Timeout" + : error.reason === "unavailable" + ? "Unavailable" + : "Unexpected" + +const cancelled = (): AuthFailure => "Cancelled" +const rejected = (): AuthFailure => "Rejected" +const expired = (): AuthFailure => "Expired" +const unavailable = (): AuthFailure => "Unavailable" +const unexpected = (): AuthFailure => "Unexpected" + +/** The failures every auth operation shares: the transport, the session and the server's hooks. */ +export const operationReasons = { + OperationHttpError: operationHttpReason, + PendingAuthenticationInvalid: expired, + StaleAuthentication: expired, + SessionInvalid: expired, + SessionStepUpInvalid: expired, + AuthenticationRequired: expired, + AssuranceRequired: expired, + HookDenied: (): AuthFailure => "Denied", + SessionUnavailable: unavailable, + SessionSignOutUnavailable: unavailable, + SessionCapabilityUnsupported: unavailable, + InvalidOperationInput: unexpected, + OperationForbidden: unexpected, + OperationPrivateOutputUnsupported: unexpected, + SessionConflict: unexpected, + SessionRenewalTooEarly: unexpected, +} as const + +/** Exhaustive: a new error in the client's contract must be classified here. */ +export const authFailureOf = (error: AuthError): AuthFailure => + Match.valueTags(error, { + ...operationReasons, + PasskeyBrowserNotCompleted: cancelled, + PasskeyBrowserBusy: cancelled, + PasskeyBrowserUnsupported: (): AuthFailure => "PasskeyUnsupported", + PasskeyBrowserUnavailable: (): AuthFailure => "PasskeyUnsupported", + // The browser refused the server's options: nothing the visitor can change. + PasskeyBrowserInputRejected: unexpected, + PasskeyRejected: rejected, + OAuthRejected: rejected, + PasskeyActionRequired: expired, + IdentityConflict: (): AuthFailure => "AccountConflict", + PasskeyUnavailable: unavailable, + PasskeyMethodUnsupported: unavailable, + OAuthUnavailable: unavailable, + OAuthMethodUnsupported: unavailable, + }) + +const failed = (error: AuthError) => new AuthFlowError({ reason: authFailureOf(error) }) -const failed = (error: { readonly _tag: string }) => - new AuthFlowError({ message: describeFailure(error._tag) }) +/** A finished call that issued no session: the flow cannot carry on from here. */ +type Outcome = { readonly _tag: "Authenticated" | "PendingAuthentication" | "RegistrationAccepted" } + +const requireSession = (outcome: Outcome): Effect.Effect => + Match.value(outcome._tag).pipe( + Match.when("Authenticated", () => Effect.void), + // A second factor Hazel does not ask for: the pending sign-in can't be finished here. + Match.when("PendingAuthentication", () => Effect.fail(new AuthFlowError({ reason: "Expired" }))), + // The account exists but this call did not sign it in. + Match.when("RegistrationAccepted", () => Effect.fail(new AuthFlowError({ reason: "AccountCreated" }))), + Match.exhaustive, + ) const newId = Effect.sync(() => crypto.randomUUID()) @@ -53,25 +138,22 @@ export const startOAuthSignIn = (provider: Provider, returnTo: string | null) => export const signInWithPasskey = (returnTo: string | null) => Effect.gen(function* () { const client = yield* HazelAuthClient - const browser = yield* PasskeyBrowser.make() - const started = yield* client.auth.passkeySignIn({ - flowId: yield* newId, - commandId: yield* newId, - profileId: "default", - }) - const response = yield* browser.authenticate({ started, mediation: "required" }) - const result = yield* client.auth.completePasskeySignIn({ - flowId: response.flowId, - response: Redacted.value(response.response), - }) - if (result._tag !== "Authenticated") return yield* new AuthFlowError({ message: describeFailure("") }) + const result = yield* Effect.gen(function* () { + const browser = yield* PasskeyBrowser.make() + const started = yield* client.auth.passkeySignIn({ + flowId: yield* newId, + commandId: yield* newId, + profileId: "default", + }) + const response = yield* browser.authenticate({ started, mediation: "required" }) + return yield* client.auth.completePasskeySignIn({ + flowId: response.flowId, + response: Redacted.value(response.response), + }) + }).pipe(Effect.mapError(failed)) + yield* requireSession(result) yield* enterApp(returnTo) - }).pipe( - Effect.catchIf( - (error) => error._tag !== "AuthFlowError", - (error) => Effect.fail(failed(error)), - ), - ) + }) export interface RegistrationInput { readonly flowId: string @@ -84,17 +166,14 @@ export interface RegistrationInput { export const register = (input: RegistrationInput) => Effect.gen(function* () { const client = yield* HazelAuthClient - const result = yield* client.auth.register({ - flowId: input.flowId, - reference: input.reference, - commandId: yield* newId, - registration: { firstName: input.firstName.trim(), lastName: input.lastName.trim() }, - }) - if (result._tag !== "Authenticated") return yield* new AuthFlowError({ message: describeFailure("") }) + const result = yield* client.auth + .register({ + flowId: input.flowId, + reference: input.reference, + commandId: yield* newId, + registration: { firstName: input.firstName.trim(), lastName: input.lastName.trim() }, + }) + .pipe(Effect.mapError(failed)) + yield* requireSession(result) yield* enterApp(null) - }).pipe( - Effect.catchIf( - (error) => error._tag !== "AuthFlowError", - (error) => Effect.fail(failed(error)), - ), - ) + }) diff --git a/apps/web-foldkit/src/auth/return-to.ts b/apps/web-foldkit/src/auth/return-to.ts index 93b786566..b702ff5b4 100644 --- a/apps/web-foldkit/src/auth/return-to.ts +++ b/apps/web-foldkit/src/auth/return-to.ts @@ -30,6 +30,24 @@ export const rememberReturnTo = (returnTo: string | null): Effect.Effect = } }) +/** The remembered path, left in place: it applies until a redirect actually goes there (`forgetReturnTo`). */ +export const readReturnTo: Effect.Effect = Effect.sync(() => { + try { + return safeReturnTo(sessionStorage.getItem(KEY)) + } catch { + return null + } +}) + +/** Drops the remembered path once the app went there. */ +export const forgetReturnTo: Effect.Effect = Effect.sync(() => { + try { + sessionStorage.removeItem(KEY) + } catch { + // Storage unavailable (private mode): nothing was remembered. + } +}) + /** The remembered path, removed as it is read so it applies to one sign-in only. */ export const takeReturnTo: Effect.Effect = Effect.sync(() => { try { diff --git a/apps/web-foldkit/src/chat/embeds/url-embeds.ts b/apps/web-foldkit/src/chat/embeds/url-embeds.ts index 553881246..80b73c61a 100644 --- a/apps/web-foldkit/src/chat/embeds/url-embeds.ts +++ b/apps/web-foldkit/src/chat/embeds/url-embeds.ts @@ -1,6 +1,6 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { IconHeart } from "../../icons" -import type { TweetView, Unfurl } from "../unfurl" +import { linkPreviewOf, type TweetView, tweetOf, type Unfurl } from "../unfurl" /** * `MessageContent.Embeds`' URL embeds: `TweetEmbed`, `YoutubeEmbed`, `GifEmbed` and `LinkPreview` @@ -13,8 +13,9 @@ const hostOf = (url: string) => (URL.canParse(url) ? new URL(url).host : "") /** `LinkPreview`: nothing once the fetch failed, a "Loading preview…" card until it answers. */ export const linkPreviewView = (h: HtmlBuilder, url: string, unfurl: Unfurl | null): Html => { - if (unfurl === null || unfurl._tag === "Failed" || unfurl._tag === "LoadedTweet") return h.empty - const og = unfurl._tag === "LoadedLinkPreview" ? unfurl.preview : null + const isLoading = unfurl !== null && (unfurl._tag === "Idle" || unfurl._tag === "Loading") + const og = linkPreviewOf(unfurl) + if (!isLoading && og === null) return h.empty const href = og?.url || url const host = hostOf(href) const imageUrl = og?.image?.url @@ -24,14 +25,20 @@ export const linkPreviewView = (h: HtmlBuilder, url: string, unfurl: Unfur h.Attribute("target", "_blank"), h.Attribute("rel", "noopener noreferrer"), h.Class( - "mt-2 block max-w-sm overflow-hidden rounded-lg border pressed:border-fg/15 bg-muted/40 pressed:bg-muted hover:border-fg/15 hover:bg-muted", + "mt-2 block max-w-sm overflow-hidden rounded-lg border data-pressed:border-fg/15 bg-muted/40 data-pressed:bg-muted hover:border-fg/15 hover:bg-muted", ), ], [ imageUrl ? h.div( [h.Class("aspect-video w-full overflow-hidden bg-muted")], - [h.img([h.Attribute("src", imageUrl), h.Attribute("alt", ""), h.Class("h-full w-full object-cover")])], + [ + h.img([ + h.Attribute("src", imageUrl), + h.Attribute("alt", ""), + h.Class("h-full w-full object-cover"), + ]), + ], ) : h.empty, h.div( @@ -42,14 +49,17 @@ export const linkPreviewView = (h: HtmlBuilder, url: string, unfurl: Unfur [ h.div([h.Class("truncate font-semibold text-sm")], [og?.title || host || url]), og?.description - ? h.div([h.Class("mt-0.5 line-clamp-2 text-[12px] text-fg/70")], [og.description]) + ? h.div( + [h.Class("mt-0.5 line-clamp-2 text-[12px] text-fg/70")], + [og.description], + ) : h.empty, h.div([h.Class("mt-1 text-[11px] text-primary-subtle-fg")], [host]), ], ), ], ), - unfurl._tag === "Loading" + isLoading ? h.div([h.Class("px-3 pb-3 text-[11px] text-muted-fg")], ["Loading preview…"]) : h.empty, ], @@ -63,7 +73,7 @@ export const youtubeView = (h: HtmlBuilder, embedUrl: string): Html => h.div( [ h.Class( - "mt-2 w-full max-w-xl overflow-hidden rounded-lg border border-fg/15 pressed:border-fg/15 bg-muted/40 pressed:bg-muted hover:border-fg/15 hover:bg-muted", + "mt-2 w-full max-w-xl overflow-hidden rounded-lg border border-fg/15 data-pressed:border-fg/15 bg-muted/40 data-pressed:bg-muted hover:border-fg/15 hover:bg-muted", ), ], [ @@ -93,7 +103,11 @@ export const gifView = (h: HtmlBuilder, mediaUrl: string, isKlipy: boolean [h.Class("mt-1")], [ h.button( - [h.Attribute("type", "button"), h.Class("cursor-pointer"), ...(onOpen === null ? [] : [h.OnClick(onOpen)])], + [ + h.Attribute("type", "button"), + h.Class("cursor-pointer"), + ...(onOpen === null ? [] : [h.OnClick(onOpen)]), + ], [ h.img([ h.Attribute("src", mediaUrl), @@ -118,8 +132,7 @@ export const gifView = (h: HtmlBuilder, mediaUrl: string, isKlipy: boolean // MARK: Tweet -const tweetCard = - "mt-2 flex max-w-sm flex-col gap-2 rounded-lg border border-fg/15 bg-muted/40 p-4" +const tweetCard = "mt-2 flex max-w-sm flex-col gap-2 rounded-lg border border-fg/15 bg-muted/40 p-4" const tweetSkeleton = (h: HtmlBuilder): Html => h.div( @@ -149,7 +162,12 @@ const tweetNotFound = (h: HtmlBuilder): Html => const truncate = (value: string, length: number) => value.length <= length ? value : `${value.slice(0, length - 3)}...` -const externalLink = (h: HtmlBuilder, href: string, className: string | null, children: ReadonlyArray) => +const externalLink = ( + h: HtmlBuilder, + href: string, + className: string | null, + children: ReadonlyArray, +) => h.a( [ h.Href(href), @@ -162,7 +180,11 @@ const externalLink = (h: HtmlBuilder, href: string, className: string | nu const verifiedIcon = (h: HtmlBuilder): Html => h.svg( - [h.Attribute("aria-label", "Verified Account"), h.Attribute("viewBox", "0 0 24 24"), h.Class("ml-1 inline size-4 text-verified")], + [ + h.Attribute("aria-label", "Verified Account"), + h.Attribute("viewBox", "0 0 24 24"), + h.Class("ml-1 inline size-4 text-verified"), + ], [ h.g( [h.Attribute("fill", "currentColor")], @@ -225,10 +247,15 @@ const tweetHeader = (h: HtmlBuilder, tweet: TweetView): Html => h.div( [], [ - externalLink(h, tweet.user.url, "flex items-center whitespace-nowrap font-semibold text-sm hover:underline", [ - truncate(tweet.user.name, 20), - tweet.user.isVerified ? verifiedIcon(h) : h.empty, - ]), + externalLink( + h, + tweet.user.url, + "flex items-center whitespace-nowrap font-semibold text-sm hover:underline", + [ + truncate(tweet.user.name, 20), + tweet.user.isVerified ? verifiedIcon(h) : h.empty, + ], + ), h.div( [h.Class("flex items-center space-x-1")], [ @@ -242,7 +269,10 @@ const tweetHeader = (h: HtmlBuilder, tweet: TweetView): Html => ), ], ), - externalLink(h, tweet.url, null, [h.span([h.Class("sr-only")], ["Link to tweet"]), twitterIcon(h)]), + externalLink(h, tweet.url, null, [ + h.span([h.Class("sr-only")], ["Link to tweet"]), + twitterIcon(h), + ]), ], ) @@ -266,7 +296,11 @@ const tweetBody = (h: HtmlBuilder, tweet: TweetView): Html => ), ) -const tweetMedia = (h: HtmlBuilder, tweet: TweetView, toOpenPhoto: ((index: number) => M) | null): Html => { +const tweetMedia = ( + h: HtmlBuilder, + tweet: TweetView, + toOpenPhoto: ((index: number) => M) | null, +): Html => { if (tweet.video === null && tweet.photos === null) return h.empty return h.div( [h.Class("flex flex-1 items-center justify-center")], @@ -332,9 +366,16 @@ const tweetMetrics = (h: HtmlBuilder, tweet: TweetView): Html => { [h.Class("flex items-center gap-4 text-fg/70 text-xs")], [ replyCount !== null && replyCount > 0 ? metric(h, replyCount, "Replies", h.empty) : h.empty, - retweetCount !== null && retweetCount > 0 ? metric(h, retweetCount, "Retweets", h.empty) : h.empty, + retweetCount !== null && retweetCount > 0 + ? metric(h, retweetCount, "Retweets", h.empty) + : h.empty, favoriteCount !== null && favoriteCount > 0 - ? metric(h, favoriteCount, favoriteCount > 1 ? "Likes" : "Like", IconHeart(h, { className: "size-3 text-primary" })) + ? metric( + h, + favoriteCount, + favoriteCount > 1 ? "Likes" : "Like", + IconHeart(h, { className: "size-3 text-primary" }), + ) : h.empty, ], ) @@ -346,15 +387,20 @@ export const tweetView = ( unfurl: Unfurl | null, toOpenPhoto: ((index: number) => M) | null, ): Html => { - if (unfurl === null || unfurl._tag === "Loading") return tweetSkeleton(h) - if (unfurl._tag !== "LoadedTweet") return tweetNotFound(h) - const { tweet } = unfurl + if (unfurl === null || unfurl._tag === "Idle" || unfurl._tag === "Loading") return tweetSkeleton(h) + const tweet = tweetOf(unfurl) + if (tweet === null) return tweetNotFound(h) return h.div( [ h.Class( - "mt-2 flex max-w-sm flex-col gap-2 overflow-hidden rounded-lg border border-fg/15 pressed:border-fg/15 bg-muted/40 pressed:bg-muted p-4 hover:border-fg/15 hover:bg-muted", + "mt-2 flex max-w-sm flex-col gap-2 overflow-hidden rounded-lg border border-fg/15 data-pressed:border-fg/15 bg-muted/40 data-pressed:bg-muted p-4 hover:border-fg/15 hover:bg-muted", ), ], - [tweetHeader(h, tweet), tweetBody(h, tweet), tweetMedia(h, tweet, toOpenPhoto), tweetMetrics(h, tweet)], + [ + tweetHeader(h, tweet), + tweetBody(h, tweet), + tweetMedia(h, tweet, toOpenPhoto), + tweetMetrics(h, tweet), + ], ) } diff --git a/apps/web-foldkit/src/chat/image-viewer.ts b/apps/web-foldkit/src/chat/image-viewer.ts index db03b0426..94ce075db 100644 --- a/apps/web-foldkit/src/chat/image-viewer.ts +++ b/apps/web-foldkit/src/chat/image-viewer.ts @@ -119,7 +119,7 @@ const ImageViewerPortal = Mount.defineStream("ImageViewerPortal", { Stream.callback((queue) => Effect.acquireRelease( Effect.sync(() => { - const releasePortal = portalOverlay(element, { isModal: false }) + const releasePortal = portalOverlay(element) const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") Queue.offerUnsafe(queue, Intent.PressedClose()) } @@ -140,9 +140,9 @@ const ImageViewerPortal = Mount.defineStream("ImageViewerPortal", { ), }) -const toolbarButton = (h: HtmlBuilder, onPress: M | undefined, icon: Html): Html => +const toolbarButton = (h: HtmlBuilder, onPress: M | undefined, label: string, icon: Html): Html => // `TooltipTrigger` is a bare React Aria Button wrapping the action Button; the tooltip shows on hover only. - ariaButton(h, { className: "react-aria-Button" }, [ + ariaButton(h, {}, [ button( h, { @@ -150,6 +150,7 @@ const toolbarButton = (h: HtmlBuilder, onPress: M | undefined, icon: Html) size: "sq-sm", ...(onPress === undefined ? {} : { onPress }), className: "text-white hover:bg-white/10", + attributes: [h.AriaLabel(label)], }, [icon], ), @@ -183,11 +184,11 @@ export const imageViewerView = (h: HtmlBuilder, inputs: ImageViewerInputs< const hasMany = images.length > 1 const act = (action: ImageViewerAction) => inputs.toAction && current ? inputs.toAction(action, current) : undefined - const actions: ReadonlyArray = [ - [act("download"), IconDownload(h, { className: "size-4" })], - [act("copyUrl"), IconCopy(h, { className: "size-4" })], - [act("openInBrowser"), IconExternalLink(h, { className: "size-4" })], - [inputs.toClose(), IconClose(h, { className: "size-4" })], + const actions: ReadonlyArray = [ + [act("download"), "Download", IconDownload(h, { className: "size-4" })], + [act("copyUrl"), "Copy image URL", IconCopy(h, { className: "size-4" })], + [act("openInBrowser"), "Open in browser", IconExternalLink(h, { className: "size-4" })], + [inputs.toClose(), "Close", IconClose(h, { className: "size-4" })], ] const toMessage = (intent: Intent) => intent._tag === "PressedClose" ? inputs.toClose() : inputs.toSelect(intent.index) @@ -286,7 +287,7 @@ export const imageViewerView = (h: HtmlBuilder, inputs: ImageViewerInputs< "absolute top-5 right-5 flex gap-1 rounded-md border border-white/10 bg-black/50 p-1", ), ], - actions.map(([onPress, icon]) => toolbarButton(h, onPress, icon)), + actions.map(([onPress, label, icon]) => toolbarButton(h, onPress, label, icon)), ), ...(hasMany ? [ diff --git a/apps/web-foldkit/src/chat/markdown/leaves.test.ts b/apps/web-foldkit/src/chat/markdown/leaves.test.ts new file mode 100644 index 000000000..2cb140c91 --- /dev/null +++ b/apps/web-foldkit/src/chat/markdown/leaves.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest" +import { viewerLeafClassName } from "../../editor/decorations" +import { codeBlockLeaves, textLeaves } from "./leaves" + +/** The message viewer's leaves, now from the composer's decorations and the Prism code highlighter. */ + +describe("text leaves", () => { + test("markers are hidden, content keeps its style", () => { + expect(textLeaves("a ~~b~~").map((leaf) => [leaf.text, viewerLeafClassName(leaf)])).toEqual([ + ["a ", ""], + ["~~", "hidden"], + ["b", "line-through"], + ["~~", "hidden"], + ]) + }) + + test("links keep their url and text", () => { + expect(textLeaves("see [docs](https://hazel.sh)")).toEqual([ + { text: "see " }, + { + text: "[docs](https://hazel.sh)", + type: "link", + isMarker: false, + url: "https://hazel.sh", + linkText: "docs", + }, + ]) + }) + + test("an emoji with a shortcode is its own leaf", () => { + const leaves = textLeaves("hi 👍") + expect(leaves.map((leaf) => leaf.text)).toEqual(["hi ", "👍"]) + expect(leaves[1]).toMatchObject({ type: "emoji", emoji: "👍" }) + expect(leaves[1]?.shortcode).toBeTruthy() + }) +}) + +describe("code block leaves", () => { + test("a known language is split into Prism tokens", () => { + const leaves = codeBlockLeaves("const x = 1", "typescript") + expect(leaves.map((leaf) => leaf.text).join("")).toBe("const x = 1") + expect(leaves.find((leaf) => leaf.text === "const")?.tokenClass).toBe("token keyword") + }) + + test("every language the legacy loader knew has a grammar, aliases included", () => { + for (const language of ["php", "rust", "rs", "yml", "md", "rb", "go", "tsx", "sql", "kotlin"]) + expect(codeBlockLeaves("x = 1", language)[0]?.tokenClass, language).toMatch(/^token/) + }) + + test("no language is Prism plaintext; an unknown one is one unstyled leaf", () => { + expect(codeBlockLeaves("x = 1", null)).toEqual([{ text: "x = 1", tokenClass: "token" }]) + expect(codeBlockLeaves("x = 1", "brainfuck")).toEqual([{ text: "x = 1", tokenClass: "" }]) + }) +}) diff --git a/apps/web-foldkit/src/chat/markdown/leaves.ts b/apps/web-foldkit/src/chat/markdown/leaves.ts index 49f12d505..03314796d 100644 --- a/apps/web-foldkit/src/chat/markdown/leaves.ts +++ b/apps/web-foldkit/src/chat/markdown/leaves.ts @@ -1,26 +1,22 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { - type CustomDescendant, - type CustomElement, - markdownLeafClassName, - type ViewerLeaf, -} from "~/components/chat/slate-editor/slate-message-viewer-model" +import { codeLeafClassName, codeLeaves } from "../../editor/code-highlight" +import { LINK_CLASS, type LeafProps, viewerLeafClassName, viewerLeaves } from "../../editor/decorations" -/** Slate's text and leaf DOM for the read-only viewer (`String`, `Leaf`, `MarkdownLeaf`). */ +/** The read-only viewer's text and leaf DOM (Slate's `String`, `Leaf`, `MarkdownLeaf` markup). */ -export type TextNode = { readonly text: string } - -export const isText = (node: CustomDescendant): node is CustomDescendant & TextNode => "text" in node +/** A decorated run of text: markdown/emoji props, or a Prism token class inside a code block. */ +export interface ViewerLeaf extends LeafProps { + readonly text: string + readonly tokenClass?: string +} -export const childrenOf = (element: CustomElement): ReadonlyArray => - "children" in element ? (element.children as CustomDescendant[]) : [] +/** Markdown and emoji leaves of one text run (`editor/decorations.ts`). */ +export const textLeaves = (text: string): ReadonlyArray => + viewerLeaves(text).map(({ start, end, ...props }) => ({ ...props, text: text.slice(start, end) })) -export const nodeString = (node: CustomDescendant): string => - isText(node) - ? node.text - : childrenOf(node as CustomElement) - .map(nodeString) - .join("") +/** Prism leaves of a code block's text (`editor/code-highlight.ts`). */ +export const codeBlockLeaves = (code: string, language: string | null): ReadonlyArray => + codeLeaves(code, language).map((leaf) => ({ text: leaf.text, tokenClass: codeLeafClassName(leaf) })) export interface LeafContext { readonly isLast: boolean @@ -41,41 +37,29 @@ const leafString = (h: HtmlBuilder, leaf: ViewerLeaf, context: LeafContext return h.span([h.Attribute("data-slate-string", "true")], [text]) } -const LINK_CLASS = "cursor-pointer text-primary underline hover:text-primary-hover" +const externalLink = (h: HtmlBuilder, url: string, content: Html | string): Html => + h.a( + [ + h.Attribute("data-slate-leaf", "true"), + h.Href(url), + h.Attribute("target", "_blank"), + h.Attribute("rel", "noopener noreferrer"), + h.Class(LINK_CLASS), + ], + [content], + ) /** `MarkdownLeaf` in viewer mode. */ const leafView = (h: HtmlBuilder, leaf: ViewerLeaf, content: Html): Html => { const leafAttribute = h.Attribute("data-slate-leaf", "true") + if (leaf.tokenClass !== undefined) + return h.span([leafAttribute, h.Attribute("class", leaf.tokenClass)], [content]) if (leaf.type === "emoji" && leaf.shortcode) return h.span([leafAttribute, h.Attribute("role", "button"), h.Attribute("tabindex", "0")], [content]) - if ( - !leaf.token && - leaf.type === "link" && - typeof leaf.url === "string" && - typeof leaf.linkText === "string" - ) - return h.a( - [ - leafAttribute, - h.Href(leaf.url), - h.Attribute("target", "_blank"), - h.Attribute("rel", "noopener noreferrer"), - h.Class(LINK_CLASS), - ], - [leaf.linkText], - ) - if (!leaf.token && leaf.type === "url" && typeof leaf.url === "string") - return h.a( - [ - leafAttribute, - h.Href(leaf.url), - h.Attribute("target", "_blank"), - h.Attribute("rel", "noopener noreferrer"), - h.Class(LINK_CLASS), - ], - [content], - ) - return h.span([leafAttribute, h.Attribute("class", markdownLeafClassName(leaf, "viewer"))], [content]) + if (leaf.type === "link" && leaf.url !== undefined && leaf.linkText !== undefined) + return externalLink(h, leaf.url, leaf.linkText) + if (leaf.type === "url" && leaf.url !== undefined) return externalLink(h, leaf.url, content) + return h.span([leafAttribute, h.Attribute("class", viewerLeafClassName(leaf))], [content]) } /** A Slate text node: one `data-slate-node="text"` span holding its decorated leaves. */ diff --git a/apps/web-foldkit/src/chat/markdown/markdown-view.ts b/apps/web-foldkit/src/chat/markdown/markdown-view.ts index dea421e7f..f2f1c94e8 100644 --- a/apps/web-foldkit/src/chat/markdown/markdown-view.ts +++ b/apps/web-foldkit/src/chat/markdown/markdown-view.ts @@ -1,21 +1,16 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { - codeBlockLeaves, - type CustomDescendant, - type CustomElement, - deserializeFromMarkdown, - isEmojiOnly, - viewerLeaves, -} from "~/components/chat/slate-editor/slate-message-viewer-model" +import { isEmojiOnly } from "~/lib/emoji-utils" import { cn } from "~/lib/utils" import { cx } from "~/utils/cx" +import { deserializeFromMarkdown } from "../../editor/markdown" +import type { ProseMirrorNode } from "../../editor/schema" import { IconCopy } from "../../icons" -import { childrenOf, isText, nodeString, type TextNode, textNodeView, voidChildView } from "./leaves" +import { codeBlockLeaves, textLeaves, textNodeView, voidChildView } from "./leaves" /** - * Port of `SlateMessageViewer` as a pure view: the same parse (`deserializeFromMarkdown`), the same - * decorations and leaf classes, and the DOM a read-only Slate `Editable` produces, with no editor - * instance per message (plan §3.4). + * Port of `SlateMessageViewer` as a pure view: the composer's parse (`editor/markdown.ts`) and + * decorations (`editor/decorations.ts`), rendered as the DOM a read-only Slate `Editable` produces, + * with no editor instance per message (plan §3.4). */ /** What the viewer resolves outside the markdown: mention names and custom emoji images. */ @@ -26,13 +21,15 @@ export interface MarkdownRefs { export const NO_REFS: MarkdownRefs = { mentions: [], emojis: [] } -type Path = ReadonlyArray +const childrenOf = (node: ProseMirrorNode): ReadonlyArray => { + const children: Array = [] + node.forEach((child) => children.push(child)) + return children +} -const textView = (h: HtmlBuilder, text: TextNode, path: Path, parent: CustomElement, index: number) => - textNodeView(h, viewerLeaves(text, [...path], parent), { - isLastText: index === childrenOf(parent).length - 1, - isBlockEmpty: nodeString(parent) === "", - }) +/** A text child of `parent` (Slate keeps an empty text in an empty block; ProseMirror has none). */ +const textView = (h: HtmlBuilder, text: string, parent: ProseMirrorNode, isLastText: boolean) => + textNodeView(h, textLeaves(text), { isLastText, isBlockEmpty: parent.textContent === "" }) const headingClass = (level: number) => level === 1 @@ -44,20 +41,17 @@ const headingClass = (level: number) => const COLLAPSE_THRESHOLD = 15 /** `CodeBlockElement` with controls; collapsing past 15 lines starts collapsed. */ -const codeBlockView = (h: HtmlBuilder, element: CustomElement, path: Path): Html => { - const children = childrenOf(element) - const codeText = children.map((child) => (isText(child) ? child.text : "")).join("") - const language = ("language" in element && element.language) || "plaintext" +const codeBlockView = (h: HtmlBuilder, element: ProseMirrorNode): Html => { + const codeText = element.textContent + const language: string = element.attrs.language || "plaintext" const lineCount = codeText.split("\n").length const isCollapsible = lineCount > COLLAPSE_THRESHOLD - const code = children.map((child, index) => - isText(child) - ? textNodeView(h, codeBlockLeaves(element, [...path], child, [...path, index]), { - isLastText: index === children.length - 1, - isBlockEmpty: codeText === "", - }) - : h.span([]), - ) + const code = [ + textNodeView(h, codeBlockLeaves(codeText, element.attrs.language), { + isLastText: true, + isBlockEmpty: codeText === "", + }), + ] return h.div( [h.Class("group relative my-2"), h.Attribute("data-slate-node", "element")], [ @@ -110,9 +104,9 @@ const MENTION_CLASS = "inline-block cursor-pointer rounded bg-primary/10 px-1 py-0.5 align-baseline font-medium text-primary transition-colors" /** `MentionElement`, interactive (a popover trigger). */ -const mentionView = (h: HtmlBuilder, element: CustomElement, refs: MarkdownRefs): Html => { - const userId = "userId" in element ? String(element.userId) : "" - const displayName = "displayName" in element ? String(element.displayName) : "" +const mentionView = (h: HtmlBuilder, element: ProseMirrorNode, refs: MarkdownRefs): Html => { + const userId: string = element.attrs.userId + const displayName: string = element.attrs.displayName const node = h.Attribute("data-slate-node", "element") if (userId === "channel" || userId === "here") return h.span( @@ -128,7 +122,6 @@ const mentionView = (h: HtmlBuilder, element: CustomElement, refs: Markdow "inline-block cursor-pointer rounded bg-primary/10 px-1 py-0.5 align-baseline font-medium text-primary outline-hidden transition-colors hover:bg-primary/20", ), ), - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), node, h.Attribute("tabindex", "0"), @@ -139,8 +132,8 @@ const mentionView = (h: HtmlBuilder, element: CustomElement, refs: Markdow } /** `CustomEmojiElement`: the org's image, or `:name:` when the org has no such emoji. */ -const customEmojiView = (h: HtmlBuilder, element: CustomElement, refs: MarkdownRefs): Html => { - const name = "name" in element ? String(element.name) : "" +const customEmojiView = (h: HtmlBuilder, element: ProseMirrorNode, refs: MarkdownRefs): Html => { + const name: string = element.attrs.name const node = h.Attribute("data-slate-node", "element") const imageUrl = refs.emojis.find((emoji) => emoji.name === name)?.imageUrl if (!imageUrl) @@ -167,20 +160,25 @@ const customEmojiView = (h: HtmlBuilder, element: CustomElement, refs: Mar ) } -const elementView = (h: HtmlBuilder, element: CustomElement, path: Path, refs: MarkdownRefs): Html => { - if (element.type === "code-block") return codeBlockView(h, element, path) - if (element.type === "mention") return mentionView(h, element, refs) - if (element.type === "custom-emoji") return customEmojiView(h, element, refs) - const children = childrenOf(element).map((child, index) => - isText(child) - ? textView(h, child, [...path, index], element, index) - : elementView(h, child as CustomElement, [...path, index], refs), - ) +const elementView = (h: HtmlBuilder, element: ProseMirrorNode, refs: MarkdownRefs): Html => { + const type = element.type.name + if (type === "code-block") return codeBlockView(h, element) + if (type === "mention") return mentionView(h, element, refs) + if (type === "custom-emoji") return customEmojiView(h, element, refs) + const elementChildren = childrenOf(element) + const children = + element.inlineContent && elementChildren.length === 0 + ? [textView(h, "", element, true)] + : elementChildren.map((child, index) => + child.isText + ? textView(h, child.text ?? "", element, index === elementChildren.length - 1) + : elementView(h, child, refs), + ) const node = h.Attribute("data-slate-node", "element") - if (element.type === "paragraph") return h.p([node, h.Class("my-0 last:empty:hidden")], children) - if (element.type === "list-item") return h.li([node, h.Class("my-0.5 ml-4")], children) - if (element.type === "subtext") return h.p([node, h.Class("my-0 text-muted-fg text-xs")], children) - if (element.type === "blockquote") + if (type === "paragraph") return h.p([node, h.Class("my-0 last:empty:hidden")], children) + if (type === "list-item") return h.li([node, h.Class("my-0.5 ml-4")], children) + if (type === "subtext") return h.p([node, h.Class("my-0 text-muted-fg text-xs")], children) + if (type === "blockquote") return h.blockquote( [node, h.Class("relative my-1 pl-4 italic")], [ @@ -191,15 +189,16 @@ const elementView = (h: HtmlBuilder, element: CustomElement, path: Path, r ...children, ], ) - if (element.type === "heading") { - const tag = element.level === 1 ? h.h1 : element.level === 2 ? h.h2 : h.h3 - return tag([node, h.Class(headingClass(element.level))], children) + if (type === "heading") { + const level: number = element.attrs.level + const tag = level === 1 ? h.h1 : level === 2 ? h.h2 : h.h3 + return tag([node, h.Class(headingClass(level))], children) } - if (element.type === "table") return tableView(h, element, children) - if (element.type === "table-row") return h.tr([node], children) - if (element.type === "table-cell") { - const align = "align" in element ? element.align : undefined - const isHeader = "header" in element && element.header + if (type === "table") return tableView(h, element, children) + if (type === "table-row") return h.tr([node], children) + if (type === "table-cell") { + const align: string | null = element.attrs.align + const isHeader: boolean = element.attrs.header const cellClasses = cx( "border border-border px-3 py-2", align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left", @@ -212,12 +211,9 @@ const elementView = (h: HtmlBuilder, element: CustomElement, path: Path, r } /** `TableElement`: the first row goes in a `thead` when it has header cells. */ -const tableView = (h: HtmlBuilder, element: CustomElement, children: ReadonlyArray): Html => { - const firstRow = childrenOf(element)[0] - const hasHeader = - firstRow !== undefined && - !isText(firstRow) && - childrenOf(firstRow as CustomElement).some((cell) => "header" in cell && cell.header) +const tableView = (h: HtmlBuilder, element: ProseMirrorNode, children: ReadonlyArray): Html => { + const firstRow = element.firstChild + const hasHeader = firstRow !== null && childrenOf(firstRow).some((cell) => cell.attrs.header === true) return h.div( [h.Attribute("data-slate-node", "element"), h.Class("my-2 overflow-x-auto")], [ @@ -233,7 +229,7 @@ const tableView = (h: HtmlBuilder, element: CustomElement, children: Reado /** `SlateMessageViewer`. */ export const markdownView = (h: HtmlBuilder, content: string, refs: MarkdownRefs = NO_REFS): Html => { - const value: ReadonlyArray = deserializeFromMarkdown(content) + const blocks = deserializeFromMarkdown(content) return h.div( [h.Class(cx("w-full", undefined))], [ @@ -256,11 +252,7 @@ export const markdownView = (h: HtmlBuilder, content: string, refs: Markdo h.Attribute("translate", "no"), h.Attribute("zindex", "-1"), ], - value.map((node, index) => - isText(node) - ? h.span([], [node.text]) - : elementView(h, node as CustomElement, [index], refs), - ), + blocks.map((block) => elementView(h, block, refs)), ), ], ) diff --git a/apps/web-foldkit/src/chat/message/content.ts b/apps/web-foldkit/src/chat/message/content.ts index 4d0cade5a..76b7459e5 100644 --- a/apps/web-foldkit/src/chat/message/content.ts +++ b/apps/web-foldkit/src/chat/message/content.ts @@ -9,14 +9,8 @@ import { isTweetUrl, isYoutubeUrl, } from "~/components/link-preview.utils" -import { - type CustomDescendant, - deserializeFromMarkdown, -} from "~/components/chat/slate-editor/slate-markdown-serializer" - -// The serializer alone (no Prism, no DOM), so row derivation can run in `update` and in tests. -const nodeString = (node: CustomDescendant): string => - "text" in node ? node.text : "children" in node ? node.children.map(nodeString).join("") : "" +// The parser alone (no Prism, no DOM), so row derivation can run in `update` and in tests. +import { deserializeFromMarkdown } from "../../editor/markdown" /** `MessageContent.Provider`'s URL processing: which URLs embed, and the text left to display. */ @@ -35,8 +29,7 @@ const NONE: ReadonlyArray = [] const extractUrlsFromParagraphs = (content: string): ReadonlyArray => { const urls: string[] = [] for (const node of deserializeFromMarkdown(content)) - if (!("text" in node) && node.type === "paragraph") - urls.push(...extractUrls(nodeString(node))) + if (node.type.name === "paragraph") urls.push(...extractUrls(node.textContent)) return [...new Set(urls)] } diff --git a/apps/web-foldkit/src/chat/message/parts.ts b/apps/web-foldkit/src/chat/message/parts.ts index ef6b1aa55..0c510f7f7 100644 --- a/apps/web-foldkit/src/chat/message/parts.ts +++ b/apps/web-foldkit/src/chat/message/parts.ts @@ -1,5 +1,5 @@ import type { Html, HtmlBuilder } from "foldkit/html" -import { formatDistanceToNow } from "~/lib/date-fns-shared" +import { formatDistanceToNow } from "date-fns" import { cx } from "~/utils/cx" import { IconDiscord, IconPin } from "../../icons" import { avatar } from "../../ui/avatar" diff --git a/apps/web-foldkit/src/chat/message/row.ts b/apps/web-foldkit/src/chat/message/row.ts index a46e4d820..e4d238b34 100644 --- a/apps/web-foldkit/src/chat/message/row.ts +++ b/apps/web-foldkit/src/chat/message/row.ts @@ -9,6 +9,7 @@ import { messageEmbedsView } from "../embeds" import { liveView } from "../embeds/live-view" import { gifView, linkPreviewView, tweetView, youtubeView } from "../embeds/url-embeds" import { markdownView } from "../markdown/markdown-view" +import { tweetOf } from "../unfurl" import { processMessageUrls } from "./content" import { formatTime, headerView, replySectionView, threadPreviewView } from "./parts" import { reactionButton, statusEmojiView, type TooltipContext } from "./tooltips" @@ -62,16 +63,11 @@ export const avatarButton = ( h: HtmlBuilder, row: MessageRow, attributes: ReadonlyArray, - overlay: Html, ) => h.button( [ ...attributes, h.Class("size-fit outline-hidden"), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("tabindex", "0"), - h.Attribute("type", "button"), ], [ avatar(h, { @@ -80,24 +76,38 @@ export const avatarButton = ( src: row.author.avatarUrl, seed: row.author.displayName || undefined, }), - overlay, ], ) /** One URL embed; tweet photos and GIFs open the image viewer. */ -const urlEmbedView = (h: HtmlBuilder, row: MessageRow, embed: UrlEmbed, context: RowContext): Html => { +const urlEmbedView = ( + h: HtmlBuilder, + row: MessageRow, + embed: UrlEmbed, + context: RowContext, +): Html => { const messageId = row.message.id return UrlEmbed.match(embed, { Tweet: ({ unfurl }) => { - const tweet = unfurl?._tag === "LoadedTweet" ? unfurl.tweet : null - const photos = tweet?.photos?.map((photo) => ({ url: photo.url, alt: tweet.text || "Tweet image" })) ?? [] + const tweet = tweetOf(unfurl) + const photos = + tweet?.photos?.map((photo) => ({ url: photo.url, alt: tweet.text || "Tweet image" })) ?? [] // Legacy only mounts the viewer for a message with an author. const canOpen = photos.length > 0 && row.message.author !== null - return tweetView(h, unfurl, canOpen ? (index) => context.toOpenEmbedImage(messageId, photos, index) : null) + return tweetView( + h, + unfurl, + canOpen ? (index) => context.toOpenEmbedImage(messageId, photos, index) : null, + ) }, Youtube: ({ embedUrl }) => youtubeView(h, embedUrl), Gif: ({ mediaUrl, isKlipy }) => - gifView(h, mediaUrl, isKlipy, context.toOpenEmbedImage(messageId, [{ url: mediaUrl, alt: "GIF" }], 0)), + gifView( + h, + mediaUrl, + isKlipy, + context.toOpenEmbedImage(messageId, [{ url: mediaUrl, alt: "GIF" }], 0), + ), LinkPreview: ({ url, unfurl }) => linkPreviewView(h, url, unfurl), }) } @@ -109,7 +119,11 @@ const contentView = (h: HtmlBuilder, row: MessageRow, context: RowContext< return [ row.live === null && displayContent ? markdownView(h, displayContent, row.refs) : h.empty, ...row.urlEmbeds.map((embed) => urlEmbedView(h, row, embed, context)), - messageEmbedsView(h, message.embeds, row.live === null ? h.empty : liveView(h, row.live.state, row.live.loading)), + messageEmbedsView( + h, + message.embeds, + row.live === null ? h.empty : liveView(h, row.live.state, row.live.loading), + ), ] } diff --git a/apps/web-foldkit/src/chat/message/toolbar.ts b/apps/web-foldkit/src/chat/message/toolbar.ts index 80252b099..41ea0dc3e 100644 --- a/apps/web-foldkit/src/chat/message/toolbar.ts +++ b/apps/web-foldkit/src/chat/message/toolbar.ts @@ -1,3 +1,4 @@ +import { Anchor } from "@foldkit/ui" import { MessageId } from "@hazel/schema" import { Effect, Option, Queue, Schema, Stream } from "effect" import { Mount } from "foldkit" @@ -5,44 +6,17 @@ import { defineMessageUnion } from "foldkit/message" import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { IconCopy, IconEdit, IconEmojiAdd, IconReply, IconTrash } from "../../icons" import { LIST_PORTAL_ATTRIBUTE } from "../image-viewer" -import { calculatePosition } from "../../ui/aria/position" import { button } from "../../ui/button" import { separator } from "../../ui/separator" import * as TooltipHost from "../tooltip-host" /** Port of `MessageToolbar` and the hover overlay around it (`useMessageToolbarOverlay`). */ -/** `topEmojisAtom` with no usage data yet. */ +/** The toolbar's quick reactions. */ export const TOP_EMOJIS = ["👍", "❤️", "😂", "🔥", "👀", "🎉"] as const const POSITION_PADDING = 12 -/** `useOverlayPosition({ placement: "top end", offset: -6, shouldFlip: true })`, with `zIndex: 50`. */ -const placeToolbar = (overlay: HTMLElement, target: HTMLElement) => { - overlay.style.position = "absolute" - overlay.style.zIndex = "50" - for (const side of ["top", "bottom", "left", "right"]) overlay.style.removeProperty(side) - overlay.style.top = "0px" - overlay.style.maxHeight = `${window.visualViewport?.height ?? window.innerHeight}px` - const result = calculatePosition({ - placement: "top right", - overlayNode: overlay, - targetNode: target, - scrollNode: overlay, - padding: POSITION_PADDING, - shouldFlip: true, - boundaryElement: document.body, - offset: -6, - crossOffset: 0, - maxHeight: undefined, - arrowSize: 0, - arrowBoundaryOffset: 0, - }) - for (const side of ["top", "bottom", "left", "right"]) overlay.style.removeProperty(side) - overlay.style.maxHeight = `${result.maxHeight}px` - for (const [key, value] of Object.entries(result.position)) overlay.style.setProperty(key, `${value}px`) -} - export const ToolbarEvent = defineMessageUnion({ EnteredToolbar: {}, LeftToolbar: {} }) export type ToolbarEvent = typeof ToolbarEvent.Type @@ -61,24 +35,30 @@ export const PlaceMessageToolbar = Mount.defineStream("PlaceMessageToolbar", { Effect.sync(() => { if (!(element instanceof HTMLElement)) return () => undefined element.style.position = "absolute" - element.style.top = "0px" + element.style.zIndex = "50" document.body.appendChild(element) - const targetOf = () => - document.getElementById(`message-${element.getAttribute(TOOLBAR_TARGET_ATTRIBUTE)}`) - // Observer callbacks run after the frame's layout and before its paint (a newly observed - // element always reports once), so placement never forces a layout of the patched list. - const observer = new ResizeObserver(() => { - const target = targetOf() - if (target !== null) placeToolbar(element, target) - }) - const observeTarget = () => { - observer.disconnect() - observer.observe(element) - const target = targetOf() - if (target !== null) observer.observe(target) + // `useOverlayPosition({ placement: "top end", offset: -6 })`: anchored to the hovered + // message, re-anchored whenever the view retargets the toolbar. + let releaseAnchor = () => undefined as void + const anchor = () => { + releaseAnchor() + const messageId = element.getAttribute(TOOLBAR_TARGET_ATTRIBUTE) + releaseAnchor = + messageId !== null && document.getElementById(`message-${messageId}`) !== null + ? Anchor.anchorSetup(element, { + buttonId: `message-${messageId}`, + anchor: { + placement: "top-end", + gap: -6, + padding: POSITION_PADDING, + portal: false, + }, + interceptTab: false, + }) + : () => undefined } - observeTarget() - const attributeObserver = new MutationObserver(observeTarget) + anchor() + const attributeObserver = new MutationObserver(anchor) attributeObserver.observe(element, { attributes: true, attributeFilter: [TOOLBAR_TARGET_ATTRIBUTE], @@ -89,7 +69,7 @@ export const PlaceMessageToolbar = Mount.defineStream("PlaceMessageToolbar", { element.addEventListener("mouseleave", onLeave) return () => { attributeObserver.disconnect() - observer.disconnect() + releaseAnchor() element.removeEventListener("mouseenter", onEnter) element.removeEventListener("mouseleave", onLeave) element.remove() @@ -198,7 +178,7 @@ export const TrackMessageHover = Mount.defineStream("TrackMessageHover", { }) export interface ToolbarInputs { - readonly messageId: string + readonly messageId: MessageId readonly isOwnMessage: boolean readonly tooltip: TooltipHost.Model readonly hoveredKey: string | null @@ -234,30 +214,38 @@ const action = ( ): Html => TooltipHost.tooltipTrigger(h, { key: `${inputs.messageId}:toolbar:${options.key}`, + messageId: inputs.messageId, active: inputs.tooltip, hoveredKey: inputs.hoveredKey, toMessage: inputs.toTooltipMessage, content: [options.label], + // The tooltip and the emoji picker render beside the button, never inside it. toTrigger: (attributes, overlay) => - button( - h, - { - size: "sq-sm", - intent: "plain", - className: options.className, - ...(options.onPress === undefined ? {} : { onPress: options.onPress }), - attributes: [ - ...attributes, - ...(options.extra ?? []), - h.Attribute("aria-label", options.label), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - ...(inputs.focusedKey === `${inputs.messageId}:toolbar:${options.key}` - ? [h.Attribute("data-focused", "true")] - : []), - ], - }, - [options.content, overlay, options.overlay ?? h.empty], + h.div( + [h.Class("contents")], + [ + button( + h, + { + size: "sq-sm", + intent: "plain", + className: options.className, + ...(options.onPress === undefined ? {} : { onPress: options.onPress }), + attributes: [ + ...attributes, + ...(options.extra ?? []), + h.Attribute("aria-label", options.label), + h.Attribute("data-react-aria-pressable", "true"), + ...(inputs.focusedKey === `${inputs.messageId}:toolbar:${options.key}` + ? [h.Attribute("data-focused", "true")] + : []), + ], + }, + [options.content], + ), + overlay, + options.overlay ?? h.empty, + ], ), }) diff --git a/apps/web-foldkit/src/chat/message/tooltips.ts b/apps/web-foldkit/src/chat/message/tooltips.ts index 1369caa2e..6a66d12d4 100644 --- a/apps/web-foldkit/src/chat/message/tooltips.ts +++ b/apps/web-foldkit/src/chat/message/tooltips.ts @@ -1,3 +1,4 @@ +import type { MessageId } from "@hazel/schema" import type { Html, HtmlBuilder } from "foldkit/html" import { cn } from "~/lib/utils" import { formatStatusExpiration } from "~/utils/status" @@ -16,7 +17,6 @@ export interface TooltipContext { } const PRESSABLE = (h: HtmlBuilder) => [ - h.Attribute("data-rac", ""), h.Attribute("data-react-aria-pressable", "true"), h.Attribute("tabindex", "0"), h.Attribute("type", "button"), @@ -63,21 +63,24 @@ const reactedBy = ( export const reactionButton = ( h: HtmlBuilder, - messageId: string, + messageId: MessageId, reaction: AggregatedReaction, context: TooltipContext, onPress?: M, ): Html => TooltipHost.tooltipTrigger(h, { key: `${messageId}:reaction:${reaction.emoji}`, + messageId, active: context.active, hoveredKey: context.hoveredKey, delayMs: 300, toMessage: context.toMessage, placement: "top", className: "flex items-center gap-3 px-3 py-2", + // Keyed by emoji: a reaction dropping to zero must not hand its hover and focus to the next pill. toTrigger: (attributes, overlay) => - h.button( + h.keyed("button")( + reaction.emoji, [ ...attributes, h.Class( @@ -119,7 +122,7 @@ export const reactionButton = ( /** `StatusEmojiWithTooltip` (interactive, no quiet hours). */ export const statusEmojiView = ( h: HtmlBuilder, - messageId: string, + messageId: MessageId, status: StatusEmoji | null, context: TooltipContext, ): Html => { @@ -129,6 +132,7 @@ export const statusEmojiView = ( ) return TooltipHost.tooltipTrigger(h, { key: `${messageId}:status`, + messageId, active: context.active, hoveredKey: context.hoveredKey, delayMs: 300, diff --git a/apps/web-foldkit/src/chat/popover-host.ts b/apps/web-foldkit/src/chat/popover-host.ts index a66efcb72..c61f0fcf7 100644 --- a/apps/web-foldkit/src/chat/popover-host.ts +++ b/apps/web-foldkit/src/chat/popover-host.ts @@ -1,24 +1,17 @@ import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" -import type { Placement } from "../ui/aria/position" +import type { Placement } from "../ui/aria/placement" import * as Popover from "../ui/popover" /** * Popovers opened from many triggers (author avatars). Only one is open at a time, so the page keeps - * one kit Popover Model keyed by trigger; every other trigger renders a shared closed Model. + * one kit Popover Model keyed by trigger; every other trigger renders a closed Model. */ -const closedModels = new Map() -const closedModel = (key: string) => { - let model = closedModels.get(key) - if (model === undefined) closedModels.set(key, (model = Popover.init(key))) - return model -} - export interface PopoverTriggerOptions { readonly key: string readonly active: { readonly key: string; readonly popover: Popover.Model } | null readonly toMessage: (key: string, message: Popover.Message) => M - readonly toTrigger: (attributes: ReadonlyArray, overlay: Html) => Html + readonly toTrigger: (attributes: ReadonlyArray) => Html readonly toContent: () => ReadonlyArray readonly placement?: Placement readonly className?: string @@ -28,7 +21,7 @@ export const popoverTrigger = (h: HtmlBuilder, options: PopoverTriggerOpti const model = options.active !== null && options.active.key === options.key ? options.active.popover - : closedModel(options.key) + : Popover.init(options.key) return h.submodel({ slotId: `popover-${options.key}`, model, diff --git a/apps/web-foldkit/src/chat/prism-modules.d.ts b/apps/web-foldkit/src/chat/prism-modules.d.ts deleted file mode 100644 index e60136aff..000000000 --- a/apps/web-foldkit/src/chat/prism-modules.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Prism language components the shared code decorator lazy-loads (no bundled types), as in apps/web. -declare module "prismjs/components/prism-c" -declare module "prismjs/components/prism-cpp" -declare module "prismjs/components/prism-csharp" -declare module "prismjs/components/prism-css" -declare module "prismjs/components/prism-diff" -declare module "prismjs/components/prism-go" -declare module "prismjs/components/prism-graphql" -declare module "prismjs/components/prism-java" -declare module "prismjs/components/prism-jsx" -declare module "prismjs/components/prism-kotlin" -declare module "prismjs/components/prism-markdown" -declare module "prismjs/components/prism-php" -declare module "prismjs/components/prism-ruby" -declare module "prismjs/components/prism-rust" -declare module "prismjs/components/prism-scss" -declare module "prismjs/components/prism-sql" -declare module "prismjs/components/prism-swift" -declare module "prismjs/components/prism-tsx" -declare module "prismjs/components/prism-yaml" diff --git a/apps/web-foldkit/src/chat/profile-popover.ts b/apps/web-foldkit/src/chat/profile-popover.ts index 9716b6cf1..653acaa9f 100644 --- a/apps/web-foldkit/src/chat/profile-popover.ts +++ b/apps/web-foldkit/src/chat/profile-popover.ts @@ -58,7 +58,6 @@ const actionBar = (h: HtmlBuilder, inputs: ProfileInputs): ReadonlyArray - message._tag === "HoveredTrigger" || message._tag === "FocusedTrigger" + message._tag === "EnteredTrigger" || + message._tag === "FocusedTrigger" || + message._tag === "PressedPointerOnTrigger" export const update = ( model: Model, - { key, delayMs, message }: Message, + { key, messageId, delayMs, message }: Message, wrap: (message: Message) => Wrapped, ): Update.Return => { const current = model !== null && model.id === key ? model : isActivating(message) - ? Tooltip.initWithDelay(key, delayMs) + ? Tooltip.init(key, delayMs) : null if (current === null) return { model } const result = Tooltip.update(current, message) return { model: result.model, commands: Command.mapMessages( - Command.mapMessages(result.commands ?? [], (inner): Message => ({ key, delayMs, message: inner })), + Command.mapMessages( + result.commands ?? [], + (inner): Message => ({ key, messageId, delayMs, message: inner }), + ), wrap, ), } @@ -48,13 +59,14 @@ export const update = ( const closedModels = new Map() const closedModel = (key: string, delayMs: number) => { let model = closedModels.get(key) - if (model === undefined || model.delayMs !== delayMs) - closedModels.set(key, (model = Tooltip.initWithDelay(key, delayMs))) + if (model === undefined || Duration.toMillis(model.showDelay) !== delayMs) + closedModels.set(key, (model = Tooltip.init(key, delayMs))) return model } export interface TriggerOptions { readonly key: string + readonly messageId: MessageId readonly active: Model /** The trigger under the pointer: React Aria's `data-hovered` on the TooltipTrigger. */ readonly hoveredKey?: string | null @@ -81,13 +93,16 @@ export const tooltipTrigger = (h: HtmlBuilder, options: TriggerOptions) toTrigger: options.hoveredKey === options.key ? (attributes, overlay) => - options.toTrigger([...attributes, ...childAttributes([h.Attribute("data-hovered", "true")])], overlay) + options.toTrigger( + [...attributes, ...childAttributes([h.Attribute("data-hovered", "true")])], + overlay, + ) : options.toTrigger, content: model.isOpen ? options.content : [], placement: options.placement, className: options.className, }, toParentMessage: (message: Tooltip.Message) => - options.toMessage({ key: options.key, delayMs, message }), + options.toMessage({ key: options.key, messageId: options.messageId, delayMs, message }), }) } diff --git a/apps/web-foldkit/src/chat/unfurl.ts b/apps/web-foldkit/src/chat/unfurl.ts index e47d2a938..43e473a1d 100644 --- a/apps/web-foldkit/src/chat/unfurl.ts +++ b/apps/web-foldkit/src/chat/unfurl.ts @@ -1,6 +1,6 @@ -import { Effect, Option, Schema } from "effect" +import { Duration, Effect, Option, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http" -import { Command } from "foldkit" +import { AsyncData, Command } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { defineTaggedUnion } from "foldkit/schema" @@ -42,7 +42,9 @@ export const TweetView = Schema.Struct({ isVerified: Schema.Boolean, }), entities: Schema.Array(TweetEntity), - photos: Schema.NullOr(Schema.Array(Schema.Struct({ url: Schema.String, width: Schema.Number, height: Schema.Number }))), + photos: Schema.NullOr( + Schema.Array(Schema.Struct({ url: Schema.String, width: Schema.Number, height: Schema.Number })), + ), video: Schema.NullOr(Schema.Struct({ poster: Schema.String, src: Schema.NullOr(Schema.String) })), favoriteCount: Schema.NullOr(Schema.Number), replyCount: Schema.NullOr(Schema.Number), @@ -50,15 +52,36 @@ export const TweetView = Schema.Struct({ }) export type TweetView = typeof TweetView.Type -/** One unfurl's state; the atom query's Initial, Success and Failure. */ -export const Unfurl = defineTaggedUnion({ - Loading: {}, - Failed: {}, - LoadedLinkPreview: { preview: LinkPreviewData }, - LoadedTweet: { tweet: TweetView }, +/** What an unfurl key resolves to: a link's Open Graph data, or a tweet. */ +export const UnfurlData = defineTaggedUnion({ + LinkPreview: { preview: LinkPreviewData }, + Tweet: { tweet: TweetView }, }) +export type UnfurlData = typeof UnfurlData.Type + +/** Why an unfurl has nothing to show: the worker failed or answered nothing usable, or it hung. */ +export const UnfurlFailure = Schema.Literals(["Unavailable", "TimedOut"]) +export type UnfurlFailure = typeof UnfurlFailure.Type + +const UnfurlAsyncData = AsyncData.Schema(UnfurlData, UnfurlFailure) + +/** One unfurl's state (the atom query's Initial, Success and Failure); only `Loading` is ever pending. */ +export const Unfurl = UnfurlAsyncData.schema export type Unfurl = typeof Unfurl.Type +/** A fetch was started. */ +export const loading: Unfurl = UnfurlAsyncData.Loading() + +/** The link preview an unfurl holds, if it has loaded one. */ +export const linkPreviewOf = (unfurl: Unfurl | null): LinkPreviewData | null => + unfurl !== null && unfurl._tag === "Success" && unfurl.data._tag === "LinkPreview" + ? unfurl.data.preview + : null + +/** The tweet an unfurl holds, if it has loaded one. */ +export const tweetOf = (unfurl: Unfurl | null): TweetView | null => + unfurl !== null && unfurl._tag === "Success" && unfurl.data._tag === "Tweet" ? unfurl.data.tweet : null + export const Unfurls = Schema.Record(Schema.String, Unfurl) export type Unfurls = typeof Unfurls.Type @@ -81,7 +104,9 @@ const RawTweet = Schema.Struct({ ), symbols: Schema.Array(Schema.Struct({ indices: Indices, text: Schema.String })), media: Schema.optionalKey( - Schema.Array(Schema.Struct({ indices: Indices, display_url: Schema.String, expanded_url: Schema.String })), + Schema.Array( + Schema.Struct({ indices: Indices, display_url: Schema.String, expanded_url: Schema.String }), + ), ), }), user: Schema.Struct({ @@ -95,7 +120,10 @@ const RawTweet = Schema.Struct({ Schema.Array(Schema.Struct({ url: Schema.String, width: Schema.Number, height: Schema.Number })), ), video: Schema.optionalKey( - Schema.Struct({ poster: Schema.String, variants: Schema.Array(Schema.Struct({ src: Schema.String })) }), + Schema.Struct({ + poster: Schema.String, + variants: Schema.Array(Schema.Struct({ src: Schema.String })), + }), ), favorite_count: Schema.optionalKey(Schema.Number), reply_count: Schema.optionalKey(Schema.Number), @@ -112,7 +140,10 @@ interface Span { } /** `addEntities`: splits the text span that contains each entity around it. */ -const addEntities = (result: Span[], entities: ReadonlyArray & { type: Span["type"] }>) => { +const addEntities = ( + result: Span[], + entities: ReadonlyArray & { type: Span["type"] }>, +) => { for (const entity of entities) { const index = result.findIndex( (item) => item.indices[0] <= entity.indices[0] && item.indices[1] >= entity.indices[1], @@ -121,9 +152,19 @@ const addEntities = (result: Span[], entities: ReadonlyArray const item = result[index]! const pieces: Span[] = [entity] if (item.indices[0] < entity.indices[0]) - pieces.unshift({ indices: [item.indices[0], entity.indices[0]], type: "text", href: null, text: null }) + pieces.unshift({ + indices: [item.indices[0], entity.indices[0]], + type: "text", + href: null, + text: null, + }) if (item.indices[1] > entity.indices[1]) - pieces.push({ indices: [entity.indices[1], item.indices[1]], type: "text", href: null, text: null }) + pieces.push({ + indices: [entity.indices[1], item.indices[1]], + type: "text", + href: null, + text: null, + }) result.splice(index, 1, ...pieces) } } @@ -131,7 +172,9 @@ const addEntities = (result: Span[], entities: ReadonlyArray const getEntities = (tweet: RawTweet): ReadonlyArray => { const chars = Array.from(tweet.text) let rangeEnd = tweet.display_text_range[1] - const result: Span[] = [{ indices: [tweet.display_text_range[0], rangeEnd], type: "text", href: null, text: null }] + const result: Span[] = [ + { indices: [tweet.display_text_range[0], rangeEnd], type: "text", href: null, text: null }, + ] const { entities } = tweet addEntities( result, @@ -153,7 +196,12 @@ const getEntities = (tweet: RawTweet): ReadonlyArray => { ) addEntities( result, - entities.urls.map((url) => ({ indices: url.indices, type: "url", href: url.expanded_url, text: url.display_url })), + entities.urls.map((url) => ({ + indices: url.indices, + type: "url", + href: url.expanded_url, + text: url.display_url, + })), ) addEntities( result, @@ -167,7 +215,12 @@ const getEntities = (tweet: RawTweet): ReadonlyArray => { const media = entities.media ?? [] addEntities( result, - media.map((item) => ({ indices: item.indices, type: "media", href: item.expanded_url, text: item.display_url })), + media.map((item) => ({ + indices: item.indices, + type: "media", + href: item.expanded_url, + text: item.display_url, + })), ) // `fixRange`: media trims the displayed range, and the last span never runs past it. const firstMedia = media[0] @@ -201,15 +254,16 @@ const toTweetView = (tweet: RawTweet): TweetView => ({ }) /** A worker tweet response as `TweetEmbed` renders it, or nothing when it does not decode. */ -export const tweetViewOf = (raw: unknown) => Schema.decodeUnknownOption(RawTweet)(raw).pipe(Option.map(toTweetView)) +export const tweetViewOf = (raw: unknown) => + Schema.decodeUnknownOption(RawTweet)(raw).pipe(Option.map(toTweetView)) // MESSAGE export const Message = defineMessageUnion({ SucceededFetchLinkPreview: { url: Schema.String, preview: LinkPreviewData }, - FailedFetchLinkPreview: { url: Schema.String }, + FailedFetchLinkPreview: { url: Schema.String, reason: UnfurlFailure }, SucceededFetchTweet: { tweetId: Schema.String, tweet: TweetView }, - FailedFetchTweet: { tweetId: Schema.String }, + FailedFetchTweet: { tweetId: Schema.String, reason: UnfurlFailure }, }) export type Message = typeof Message.Type @@ -218,20 +272,40 @@ export const applyMessage = (unfurls: Unfurls, message: Message): Unfurls => Message.match(message, { SucceededFetchLinkPreview: ({ url, preview }) => ({ ...unfurls, - [linkPreviewKey(url)]: Unfurl.LoadedLinkPreview({ preview }), + [linkPreviewKey(url)]: UnfurlAsyncData.Success({ data: UnfurlData.LinkPreview({ preview }) }), + }), + FailedFetchLinkPreview: ({ url, reason }) => ({ + ...unfurls, + [linkPreviewKey(url)]: UnfurlAsyncData.Failure({ error: reason }), + }), + SucceededFetchTweet: ({ tweetId, tweet }) => ({ + ...unfurls, + [tweetKey(tweetId)]: UnfurlAsyncData.Success({ data: UnfurlData.Tweet({ tweet }) }), + }), + FailedFetchTweet: ({ tweetId, reason }) => ({ + ...unfurls, + [tweetKey(tweetId)]: UnfurlAsyncData.Failure({ error: reason }), }), - FailedFetchLinkPreview: ({ url }) => ({ ...unfurls, [linkPreviewKey(url)]: Unfurl.Failed() }), - SucceededFetchTweet: ({ tweetId, tweet }) => ({ ...unfurls, [tweetKey(tweetId)]: Unfurl.LoadedTweet({ tweet }) }), - FailedFetchTweet: ({ tweetId }) => ({ ...unfurls, [tweetKey(tweetId)]: Unfurl.Failed() }), }) // COMMAND +/** A hung worker must not leave the embed loading for the whole visit. */ +const UNFURL_TIMEOUT = Duration.seconds(10) + +/** + * The worker's JSON, decoded, or the reason there is none. Every failure is an unfurl that shows + * nothing (legacy renders failed previews as nothing), so they collapse into two reasons. + */ const getJson = (path: string, schema: S) => HttpClient.get(`${LINK_PREVIEW_URL}${path}`).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), Effect.provide(FetchHttpClient.layer), + Effect.timeout(UNFURL_TIMEOUT), + Effect.mapError( + (error): UnfurlFailure => (error._tag === "TimeoutError" ? "TimedOut" : "Unavailable"), + ), ) /** `LinkPreviewClient.query("linkPreview", "get", { payload: { url } })`. */ @@ -242,7 +316,7 @@ export const FetchLinkPreview = Command.define("FetchLinkPreview", { getJson(`/link-preview?${new URLSearchParams({ url })}`, LinkPreviewData).pipe( Effect.match({ onSuccess: (preview) => Message.SucceededFetchLinkPreview({ url, preview }), - onFailure: () => Message.FailedFetchLinkPreview({ url }), + onFailure: (reason) => Message.FailedFetchLinkPreview({ url, reason }), }), ), }) @@ -255,7 +329,7 @@ export const FetchTweet = Command.define("FetchTweet", { getJson(`/tweet?${new URLSearchParams({ id: tweetId })}`, RawTweet).pipe( Effect.match({ onSuccess: (tweet) => Message.SucceededFetchTweet({ tweetId, tweet: toTweetView(tweet) }), - onFailure: () => Message.FailedFetchTweet({ tweetId }), + onFailure: (reason) => Message.FailedFetchTweet({ tweetId, reason }), }), ), }) diff --git a/apps/web-foldkit/src/composer/autocomplete-view.ts b/apps/web-foldkit/src/composer/autocomplete-view.ts index 245c1023f..0cf2f177e 100644 --- a/apps/web-foldkit/src/composer/autocomplete-view.ts +++ b/apps/web-foldkit/src/composer/autocomplete-view.ts @@ -3,7 +3,14 @@ import type { Html, HtmlBuilder } from "foldkit/html" import { cx } from "~/utils/cx" import { getStatusDotColor } from "~/utils/status" import { avatar } from "../ui/avatar" -import { KeepEditorFocus, Message, type Model } from "./composer" +import { + FitAutocompleteHeight, + listboxId, + Message, + type Model, + optionDomId, + PreventEditorBlur, +} from "./composer" import { clampedActiveIndex, type MentionOption, mentionOptions } from "./update" import { commandTriggerContent, emojiTriggerContent } from "./trigger-views" @@ -12,7 +19,13 @@ import { commandTriggerContent, emojiTriggerContent } from "./trigger-views" /** `AvatarOnlineIndicator` at size xs, inside `AvatarBadge`. */ const statusDot = (h: HtmlBuilder, status: string) => h.span([ - h.Class(cx("absolute right-0 bottom-0 rounded-full ring-[1.5px] ring-bg", getStatusDotColor(status), "size-1.5")), + h.Class( + cx( + "absolute right-0 bottom-0 rounded-full ring-[1.5px] ring-bg", + getStatusDotColor(status), + "size-1.5", + ), + ), ]) /** `MentionItem` from `triggers/mention-trigger.tsx`. */ @@ -39,7 +52,10 @@ const mentionItem = (h: HtmlBuilder, option: MentionOption): Html => h.div( [h.Class("min-w-0 flex-1")], [ - h.div([h.Class("truncate font-medium")], [option.type === "user" ? option.label : `@${option.displayName}`]), + h.div( + [h.Class("truncate font-medium")], + [option.type === "user" ? option.label : `@${option.displayName}`], + ), ...(option.description ? [h.div([h.Class("truncate text-muted-fg text-xs")], [option.description])] : []), @@ -48,23 +64,29 @@ const mentionItem = (h: HtmlBuilder, option: MentionOption): Html => ], ) +/** Pressing in the list keeps the focus (and the caret) in the editor. */ +const keepFocus = (h: HtmlBuilder, toMessage: (message: Message) => M) => + h.OnMount(Mount.mapMessage(PreventEditorBlur(), toMessage)) + /** `AutocompleteListBox`: the option rows, or the trigger's empty message. */ export const listBox = ( h: HtmlBuilder, + editorId: string, options: ReadonlyArray<{ readonly id: string; readonly content: Html }>, activeIndex: number, emptyMessage: string, toMessage: (message: Message) => M, ): Html => options.length === 0 - ? h.div([h.Class("p-4 text-center text-muted-fg text-sm")], [emptyMessage]) + ? h.div([h.Class("p-4 text-center text-muted-fg text-sm"), keepFocus(h, toMessage)], [emptyMessage]) : h.div( - [h.Class(cx("p-2 outline-none"))], + [h.Class(cx("p-2 outline-none")), keepFocus(h, toMessage)], options.map((option, index) => { const isActive = index === activeIndex return h.keyed("div")( option.id, [ + h.Id(optionDomId(editorId, index)), h.Role("option"), h.Attribute("aria-selected", String(isActive)), h.OnClick(toMessage(Message.ClickedAutocompleteOption({ index }))), @@ -86,6 +108,7 @@ export const listBox = ( const mentionContent = (h: HtmlBuilder, model: Model, toMessage: (message: Message) => M): Html => listBox( h, + model.editorId, mentionOptions(model).map((option) => ({ id: option.id, content: mentionItem(h, option) })), clampedActiveIndex(model), "No users found", @@ -93,7 +116,11 @@ const mentionContent = (h: HtmlBuilder, model: Model, toMessage: (message: ) /** The popover above the editor while a trigger is open (nothing otherwise). */ -export const autocompletePopover = (h: HtmlBuilder, model: Model, toMessage: (message: Message) => M): Html => { +export const autocompletePopover = ( + h: HtmlBuilder, + model: Model, + toMessage: (message: Message) => M, +): Html => { const trigger = model.autocomplete?.trigger if (trigger === undefined) return h.empty const content = @@ -106,8 +133,9 @@ export const autocompletePopover = (h: HtmlBuilder, model: Model, toMessag return h.keyed("div")( "autocomplete", [ + h.Id(listboxId(model.editorId)), h.Role("listbox"), - h.OnMount(Mount.mapMessage(KeepEditorFocus(), toMessage)), + h.OnMount(Mount.mapMessage(FitAutocompleteHeight(), toMessage)), h.Class( cx( "absolute right-0 bottom-full left-0 z-50 mb-2", diff --git a/apps/web-foldkit/src/composer/composer.story.test.ts b/apps/web-foldkit/src/composer/composer.story.test.ts index 5d023ba79..707422616 100644 --- a/apps/web-foldkit/src/composer/composer.story.test.ts +++ b/apps/web-foldkit/src/composer/composer.story.test.ts @@ -1,47 +1,144 @@ +import { BotCommandId, BotId, UserId } from "@hazel/schema" +import { Schema } from "effect" import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test, vi } from "vitest" import * as Composer from "./composer" import * as EditorCommands from "./editor-commands" import { update } from "./update" -/** The composer Submodel's own update: mention autocomplete, slash commands and the editor sync. */ +/** The composer Submodel's own update: the draft text, mention autocomplete, slash commands and the editor sync. */ // ProseMirror's view reads `document` at import; node has none. vi.hoisted(() => { if (!("document" in globalThis)) - Object.assign(globalThis, { document: Object.assign(new EventTarget(), { documentElement: { style: {} } }) }) + Object.assign(globalThis, { + document: Object.assign(new EventTarget(), { documentElement: { style: {} } }), + }) }) +const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` const editorId = "composer-test" const { Message } = Composer -const members = [ - { userId: "user-grace", firstName: "Grace", lastName: "Hopper", avatarUrl: null }, - { userId: "user-alan", firstName: "Alan", lastName: "Turing", avatarUrl: null }, +const members: ReadonlyArray = [ + { userId: Schema.decodeSync(UserId)(uuid(1)), firstName: "Grace", lastName: "Hopper", avatarUrl: null }, + { userId: Schema.decodeSync(UserId)(uuid(2)), firstName: "Alan", lastName: "Turing", avatarUrl: null }, ] const withMembers = (): Composer.Model => ({ ...Composer.init(editorId), members }) const deployCommand: Composer.BotCommand = { - id: "command-deploy", + id: Schema.decodeSync(BotCommandId)(uuid(3)), name: "deploy", description: "Deploy a branch", - bot: { id: "bot-ci", name: "CI", avatarUrl: null }, + bot: { id: Schema.decodeSync(BotId)(uuid(4)), name: "CI", avatarUrl: null }, arguments: [ { name: "branch", description: null, required: true, placeholder: null, type: "string" }, { name: "env", description: null, required: false, placeholder: null, type: "string" }, ], } -describe("composer draft", () => { - test("editor changes leave the composer Model alone and send no Commands", () => { - const initial = Composer.init(editorId) +describe("the draft text", () => { + test("the Model owns what the editor reports, so a remounted editor starts from it", () => { story( update, - given(initial), - message(Message.UpdatedDraft({ markdown: "Hello", isEmpty: false })), + given(Composer.init(editorId)), + message(Message.UpdatedDraft({ markdown: "Hello **team**", isEmpty: false })), Command.expectNone(), - model((current) => expect(current).toBe(initial)), + model((current) => expect(current.markdown).toBe("Hello **team**")), + ) + }) + + test("an editor that is not mounted fails the Command; the Model keeps the draft", () => { + const drafted: Composer.Model = { ...Composer.init(editorId), markdown: "kept" } + story( + update, + given(drafted), + message(Message.FailedSetEditorContent({ reason: "EditorNotMounted" })), + Command.expectNone(), + model((current) => expect(current.markdown).toBe("kept")), + ) + }) +}) + +describe("mention autocomplete", () => { + test("moving through the options tells the editor which one is active (aria-activedescendant)", () => { + story( + update, + given(withMembers()), + message(Message.ChangedAutocomplete({ autocomplete: { trigger: "mention", search: "" } })), + Command.expectExact( + Composer.SyncAutocompleteOptions({ + editorId, + optionCount: 4, + activeOptionId: Composer.optionDomId(editorId, 0), + }), + ), + Command.resolve(Composer.SyncAutocompleteOptions, Message.CompletedSyncAutocompleteOptions()), + message(Message.PressedAutocompleteKey({ key: "ArrowDown" })), + Command.expectExact( + Composer.SyncAutocompleteOptions({ + editorId, + optionCount: 4, + activeOptionId: Composer.optionDomId(editorId, 1), + }), + ), + Command.resolve(Composer.SyncAutocompleteOptions, Message.CompletedSyncAutocompleteOptions()), + model((current) => expect(current.activeIndex).toBe(1)), + ) + }) + + test("Enter inserts the active member", () => { + story( + update, + given({ ...withMembers(), autocomplete: { trigger: "mention", search: "gra" } }), + message(Message.PressedAutocompleteKey({ key: "Enter" })), + Command.expectExact( + Composer.InsertMention({ editorId, userId: uuid(1), displayName: "Grace Hopper" }), + ), + Command.resolve(Composer.InsertMention, Message.CompletedInsertMention()), + ) + }) +}) + +describe("slash commands", () => { + test("picking a command opens its argument panel, idle, with the first field focused", () => { + story( + update, + given({ + ...Composer.init(editorId), + botCommands: [deployCommand], + autocomplete: { trigger: "command", search: "" }, + }), + message(Message.ClickedAutocompleteOption({ index: 0 })), + Command.expectExact( + EditorCommands.RemoveTriggerText({ editorId }), + EditorCommands.FocusCommandField({ editorId, index: 0 }), + ), + Command.resolve(EditorCommands.RemoveTriggerText, Message.CompletedRemoveTriggerText()), + Command.resolve(EditorCommands.FocusCommandField, Message.CompletedFocusCommandField()), + model((current) => + expect(current.commandInput).toEqual({ + command: deployCommand, + values: {}, + submission: "Idle", + }), + ), + ) + }) + + test("Tab moves the focus to the next field without touching the Model", () => { + const input: Composer.Model = { + ...Composer.init(editorId), + commandInput: { command: deployCommand, values: {}, submission: "Idle" }, + } + story( + update, + given(input), + message(Message.PressedCommandFieldKey({ index: 0, key: "Tab", shiftKey: false })), + Command.expectExact(EditorCommands.FocusCommandField({ editorId, index: 1 })), + Command.resolve(EditorCommands.FocusCommandField, Message.CompletedFocusCommandField()), + model((current) => expect(current).toBe(input)), ) }) }) diff --git a/apps/web-foldkit/src/composer/composer.ts b/apps/web-foldkit/src/composer/composer.ts index aac61af20..ad967cdc8 100644 --- a/apps/web-foldkit/src/composer/composer.ts +++ b/apps/web-foldkit/src/composer/composer.ts @@ -1,21 +1,24 @@ +import { BotCommandId, BotId, UserId } from "@hazel/schema" import { Effect, Match, Queue, Schema, Stream } from "effect" import { Command, File, Mount } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import type { EditorView } from "prosemirror-view" +import { CommonReason } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" import type { EditorEvent } from "../editor/editor-view" import { closeAutocomplete, createComposerEditor, - editorById, insertMention, - registerEditor, - syncAutocompleteOptionCount, - unregisterEditor, + syncAutocompleteOptions, } from "../editor/editor-view" +import { editorRegistry, withEditor } from "../editor/registry" /** * The channel composer as a Submodel: port of `SlateMessageComposer` (frame, editor, * actions) with mention autocomplete. The ProseMirror editor lives in a Mount; the - * Model holds the serialized draft, the autocomplete query and the mention candidates. + * Model owns the draft's markdown (a remounted editor starts from it), the autocomplete + * query and the mention candidates. */ // MODEL @@ -24,7 +27,7 @@ export const PresenceStatus = Schema.Literals(["online", "away", "busy", "dnd", export type PresenceStatus = typeof PresenceStatus.Type export const MentionMember = Schema.Struct({ - userId: Schema.String, + userId: UserId, firstName: Schema.String, lastName: Schema.String, avatarUrl: Schema.NullOr(Schema.String), @@ -32,19 +35,22 @@ export const MentionMember = Schema.Struct({ export type MentionMember = typeof MentionMember.Type export const MentionableBot = Schema.Struct({ - userId: Schema.String, + userId: UserId, name: Schema.String, description: Schema.NullOr(Schema.String), avatarUrl: Schema.NullOr(Schema.String), }) export type MentionableBot = typeof MentionableBot.Type +export const Presence = Schema.Struct({ userId: UserId, status: PresenceStatus }) +export type Presence = typeof Presence.Type + /** Legacy `BotCommandData` (provider is always "bot"). */ export const BotCommand = Schema.Struct({ - id: Schema.String, + id: BotCommandId, name: Schema.String, description: Schema.String, - bot: Schema.Struct({ id: Schema.String, name: Schema.String, avatarUrl: Schema.NullOr(Schema.String) }), + bot: Schema.Struct({ id: BotId, name: Schema.String, avatarUrl: Schema.NullOr(Schema.String) }), arguments: Schema.Array( Schema.Struct({ name: Schema.String, @@ -60,11 +66,15 @@ export type BotCommand = typeof BotCommand.Type export const ComposerEmoji = Schema.Struct({ name: Schema.String, imageUrl: Schema.String }) export type ComposerEmoji = typeof ComposerEmoji.Type +/** Whether the command's Execute is in flight (a second press waits for the first). */ +export const CommandSubmission = Schema.Literals(["Idle", "Executing"]) +export type CommandSubmission = typeof CommandSubmission.Type + /** Legacy `CommandInputState` while a slash command collects its arguments. */ export const CommandInput = Schema.Struct({ command: BotCommand, values: Schema.Record(Schema.String, Schema.String), - focusedFieldIndex: Schema.Number, + submission: CommandSubmission, }) export type CommandInput = typeof CommandInput.Type @@ -76,10 +86,12 @@ export type AutocompleteQuery = typeof AutocompleteQuery.Type export const Model = Schema.Struct({ editorId: Schema.String, placeholder: Schema.String, + /** The draft text. The editor reports every change; a remounted editor starts from it. */ + markdown: Schema.String, autocomplete: Schema.NullOr(AutocompleteQuery), activeIndex: Schema.Number, members: Schema.Array(MentionMember), - presence: Schema.Array(Schema.Struct({ userId: Schema.String, status: PresenceStatus })), + presence: Schema.Array(Presence), mentionableBots: Schema.Array(MentionableBot), botCommands: Schema.Array(BotCommand), customEmojis: Schema.Array(ComposerEmoji), @@ -90,6 +102,7 @@ export type Model = typeof Model.Type export const init = (editorId: string, placeholder = "Type a message..."): Model => ({ editorId, placeholder, + markdown: "", autocomplete: null, activeIndex: 0, members: [], @@ -100,10 +113,22 @@ export const init = (editorId: string, placeholder = "Type a message..."): Model commandInput: null, }) +/** The autocomplete listbox's DOM id (the editor's `aria-controls`). */ +export const listboxId = (editorId: string) => `${editorId}-autocomplete` + +/** An autocomplete option's DOM id (the editor's `aria-activedescendant`). */ +export const optionDomId = (editorId: string, index: number) => `${editorId}-option-${index}` + +/** Why a Command could not act on the editor: its element is not in the DOM, or something broke. */ +export const EditorFailure = Schema.Union([Schema.Literal("EditorNotMounted"), CommonReason]) +export type EditorFailure = typeof EditorFailure.Type + // MESSAGE export const AutocompleteKey = Schema.Literals(["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"]) +const failedOnEditor = { reason: EditorFailure } + export const Message = defineMessageUnion({ UpdatedDraft: { markdown: Schema.String, isEmpty: Schema.Boolean }, ChangedAutocomplete: { autocomplete: Schema.NullOr(AutocompleteQuery) }, @@ -115,26 +140,35 @@ export const Message = defineMessageUnion({ HoveredAutocompleteOption: { index: Schema.Number }, ClickedAutocompleteOption: { index: Schema.Number }, UpdatedMentionMembers: { members: Schema.Array(MentionMember) }, - UpdatedPresence: { presence: Schema.Array(Schema.Struct({ userId: Schema.String, status: PresenceStatus })) }, + UpdatedPresence: { presence: Schema.Array(Presence) }, + UpdatedMentionableBots: { bots: Schema.Array(MentionableBot) }, + UpdatedBotCommands: { commands: Schema.Array(BotCommand) }, + UpdatedCustomEmojis: { emojis: Schema.Array(ComposerEmoji) }, + UpdatedCommandValue: { argName: Schema.String, value: Schema.String }, + PressedCommandFieldKey: { index: Schema.Number, key: Schema.String, shiftKey: Schema.Boolean }, + ClickedExecuteCommand: {}, + ClickedCancelCommand: {}, + CompletedPreventEditorBlur: {}, + CompletedFitAutocompleteHeight: {}, CompletedInsertMention: {}, + FailedInsertMention: failedOnEditor, CompletedSyncAutocompleteOptions: {}, + FailedSyncAutocompleteOptions: failedOnEditor, CompletedCloseAutocomplete: {}, - CompletedKeepEditorFocus: {}, + FailedCloseAutocomplete: failedOnEditor, CompletedSetEditorContent: {}, + FailedSetEditorContent: failedOnEditor, CompletedClearEditor: {}, + FailedClearEditor: failedOnEditor, CompletedFocusEditor: {}, CompletedInsertEditorText: {}, + FailedInsertEditorText: failedOnEditor, CompletedInsertCustomEmoji: {}, - UpdatedMentionableBots: { bots: Schema.Array(MentionableBot) }, - UpdatedBotCommands: { commands: Schema.Array(BotCommand) }, - UpdatedCustomEmojis: { emojis: Schema.Array(ComposerEmoji) }, + FailedInsertCustomEmoji: failedOnEditor, CompletedInsertEmoji: {}, + FailedInsertEmoji: failedOnEditor, CompletedRemoveTriggerText: {}, - UpdatedCommandValue: { argName: Schema.String, value: Schema.String }, - FocusedCommandField: { index: Schema.Number }, - PressedCommandFieldKey: { index: Schema.Number, key: Schema.String, shiftKey: Schema.Boolean }, - ClickedExecuteCommand: {}, - ClickedCancelCommand: {}, + FailedRemoveTriggerText: failedOnEditor, CompletedFocusCommandField: {}, }) export type Message = typeof Message.Type @@ -164,9 +198,12 @@ const eventToMessage = (event: EditorEvent): EditorMessage => // MOUNT -/** Owns the ProseMirror editor for the element's lifetime; editor events stream out as Messages. */ +/** + * Owns the ProseMirror editor for the element's lifetime; editor events stream out as Messages. + * `initialMarkdown` is read once, when the element mounts: the Model's draft after a remount. + */ export const MountEditor = Mount.defineStream("MountEditor", { - args: { editorId: Schema.String, placeholder: Schema.String }, + args: { editorId: Schema.String, placeholder: Schema.String, initialMarkdown: Schema.String }, messages: [ Message.UpdatedDraft, Message.ChangedAutocomplete, @@ -176,29 +213,29 @@ export const MountEditor = Mount.defineStream("MountEditor", { Message.PressedArrowUpInEmpty, Message.PastedFiles, ], - execute: ({ element, editorId, placeholder, viewStateChanges }) => + execute: ({ element, editorId, placeholder, initialMarkdown, viewStateChanges }) => Stream.callback((queue) => Effect.gen(function* () { const view = yield* Effect.acquireRelease( - Effect.sync(() => { - const editor = createComposerEditor(element as HTMLElement, { + Effect.sync(() => + createComposerEditor(element as HTMLElement, { placeholder, + initialMarkdown, + listboxId: listboxId(editorId), emit: (event) => Queue.offerUnsafe(queue, eventToMessage(event)), - }) - registerEditor(editorId, editor) - return editor - }), + }), + ), // The editor is destroyed once its element has left the document: tearing it down // while still attached forces a synchronous layout of the whole page. - (editor) => - Effect.sync(() => unregisterEditor(editorId, editor)).pipe( - Effect.andThen(Effect.yieldNow), - Effect.andThen(Effect.sync(() => editor.destroy())), - ), + (editor) => Effect.yieldNow.pipe(Effect.andThen(Effect.sync(() => editor.destroy()))), ) + // Registered after the editor exists, so it is released (unregistered) before the destroy. + yield* editorRegistry.register(editorId, view) // Read-only while DevTools shows a historical view. yield* viewStateChanges.pipe( - Stream.runForEach((viewState) => Effect.sync(() => view.setProps({ editable: () => viewState === "Live" }))), + Stream.runForEach((viewState) => + Effect.sync(() => view.setProps({ editable: () => viewState === "Live" })), + ), Effect.forkScoped, ) return yield* Effect.never @@ -206,65 +243,102 @@ export const MountEditor = Mount.defineStream("MountEditor", { ), }) -/** Legacy `EditorAutocomplete`: keep focus in the editor on press, and size to the space above. */ -export const KeepEditorFocus = Mount.define("KeepEditorFocus", { - messages: [Message.CompletedKeepEditorFocus], +/** Legacy `EditorAutocomplete`'s `onMouseDown`: pressing an option keeps the focus in the editor. */ +export const PreventEditorBlur = Mount.define("PreventEditorBlur", { + messages: [Message.CompletedPreventEditorBlur], + execute: ({ element }) => + Effect.gen(function* () { + const preventBlur = (event: Event) => event.preventDefault() + yield* Effect.acquireRelease( + Effect.sync(() => element.addEventListener("mousedown", preventBlur)), + () => Effect.sync(() => element.removeEventListener("mousedown", preventBlur)), + ) + return Message.CompletedPreventEditorBlur() + }), +}) + +/** Legacy `EditorAutocomplete`'s sizing: the popover fits the space above the composer. */ +export const FitAutocompleteHeight = Mount.define("FitAutocompleteHeight", { + messages: [Message.CompletedFitAutocompleteHeight], execute: ({ element }) => Effect.gen(function* () { const popover = element as HTMLElement + const updateHeight = () => { + const container = popover.parentElement?.getBoundingClientRect() + if (container) + popover.style.maxHeight = `${Math.min(Math.max(container.top - 16, 150), 400)}px` + } yield* Effect.acquireRelease( Effect.sync(() => { - const preventBlur = (event: Event) => event.preventDefault() - const updateHeight = () => { - const container = popover.parentElement?.getBoundingClientRect() - if (container) popover.style.maxHeight = `${Math.min(Math.max(container.top - 16, 150), 400)}px` - } updateHeight() - popover.addEventListener("mousedown", preventBlur) window.addEventListener("resize", updateHeight) window.addEventListener("scroll", updateHeight, { passive: true }) - return { preventBlur, updateHeight } }), - ({ preventBlur, updateHeight }) => + () => Effect.sync(() => { - popover.removeEventListener("mousedown", preventBlur) window.removeEventListener("resize", updateHeight) window.removeEventListener("scroll", updateHeight) }), ) - return Message.CompletedKeepEditorFocus() + return Message.CompletedFitAutocompleteHeight() }), }) // COMMAND -export const withEditor = (editorId: string, f: (view: NonNullable>) => void) => - Effect.sync(() => { - const view = editorById(editorId) - if (view) f(view) - }) +/** + * Runs `f` on the live editor and settles into `completed`, or into `failed` with the reason when + * the editor is not mounted (the Model still holds the draft, so nothing is lost) or `f` broke. + */ +export const onEditor = ( + editorId: string, + f: (view: EditorView) => void, + completed: C, + failed: (payload: { readonly reason: EditorFailure }) => F, +) => + withEditor(editorId, f).pipe( + Effect.as(completed), + Effect.catchTag("EditorNotMounted", () => Effect.succeed(failed({ reason: "EditorNotMounted" }))), + catchCommon((reason) => failed({ reason })), + ) export const InsertMention = Command.define("InsertMention", { + // A user id, or "channel" / "here". args: { editorId: Schema.String, userId: Schema.String, displayName: Schema.String }, - messages: [Message.CompletedInsertMention], + messages: [Message.CompletedInsertMention, Message.FailedInsertMention], execute: ({ editorId, userId, displayName }) => - withEditor(editorId, (view) => insertMention(view, userId, displayName)).pipe( - Effect.as(Message.CompletedInsertMention()), + onEditor( + editorId, + (view) => insertMention(view, userId, displayName), + Message.CompletedInsertMention(), + Message.FailedInsertMention, ), }) export const SyncAutocompleteOptions = Command.define("SyncAutocompleteOptions", { - args: { editorId: Schema.String, optionCount: Schema.Number }, - messages: [Message.CompletedSyncAutocompleteOptions], - execute: ({ editorId, optionCount }) => - withEditor(editorId, (view) => syncAutocompleteOptionCount(view, optionCount)).pipe( - Effect.as(Message.CompletedSyncAutocompleteOptions()), + args: { + editorId: Schema.String, + optionCount: Schema.Number, + activeOptionId: Schema.NullOr(Schema.String), + }, + messages: [Message.CompletedSyncAutocompleteOptions, Message.FailedSyncAutocompleteOptions], + execute: ({ editorId, optionCount, activeOptionId }) => + onEditor( + editorId, + (view) => syncAutocompleteOptions(view, optionCount, activeOptionId), + Message.CompletedSyncAutocompleteOptions(), + Message.FailedSyncAutocompleteOptions, ), }) export const CloseAutocomplete = Command.define("CloseAutocomplete", { args: { editorId: Schema.String }, - messages: [Message.CompletedCloseAutocomplete], + messages: [Message.CompletedCloseAutocomplete, Message.FailedCloseAutocomplete], execute: ({ editorId }) => - withEditor(editorId, closeAutocomplete).pipe(Effect.as(Message.CompletedCloseAutocomplete())), + onEditor( + editorId, + closeAutocomplete, + Message.CompletedCloseAutocomplete(), + Message.FailedCloseAutocomplete, + ), }) diff --git a/apps/web-foldkit/src/composer/data.ts b/apps/web-foldkit/src/composer/data.ts index e42bef423..63eda3e88 100644 --- a/apps/web-foldkit/src/composer/data.ts +++ b/apps/web-foldkit/src/composer/data.ts @@ -7,20 +7,25 @@ import { userCollection, userPresenceStatusCollection, } from "~/db/collections" -import type { ChannelMemberId } from "@hazel/schema" +import type { BotCommandId, BotId, ChannelId, ChannelMemberId, OrganizationId, UserId } from "@hazel/schema" import { liveQueryStream } from "../data/live-query" -import type { BotCommand, MentionableBot, MentionMember, PresenceStatus } from "./composer" +import type { BotCommand, MentionableBot, MentionMember, Presence } from "./composer" /** Channel members for mention autocomplete: the exact query `useMentionOptions` runs. */ export const mentionMembersStream = ( - channelId: string, + channelId: ChannelId, toMessage: (members: ReadonlyArray) => Message, ) => - liveQueryStream<{ user?: { id: string; firstName: string; lastName: string; avatarUrl?: string | null } }, Message>( + liveQueryStream< + { user?: { id: UserId; firstName: string; lastName: string; avatarUrl?: string | null } }, + Message + >( (q) => q .from({ channelMember: channelMemberCollection }) - .innerJoin({ user: userCollection }, ({ channelMember, user }) => eq(channelMember.userId, user.id)) + .innerJoin({ user: userCollection }, ({ channelMember, user }) => + eq(channelMember.userId, user.id), + ) .where(({ channelMember }) => eq(channelMember.channelId, channelId)) .limit(100) .orderBy(({ channelMember }) => channelMember.joinedAt, "desc") @@ -43,21 +48,19 @@ export const mentionMembersStream = ( ) /** Every presence row, as `useMentionOptions` reads it. */ -export const presenceStream = ( - toMessage: (presence: ReadonlyArray<{ userId: string; status: PresenceStatus }>) => Message, -) => - liveQueryStream<{ userId: string; status: PresenceStatus }, Message>( +export const presenceStream = (toMessage: (presence: ReadonlyArray) => Message) => + liveQueryStream( (q) => q.from({ presence: userPresenceStatusCollection }).select(({ presence }) => presence), (rows) => toMessage(rows.map((row) => ({ userId: row.userId, status: row.status }))), ) /** `useMentionOptions`' mentionable bots installed in the organization. */ export const mentionableBotsStream = ( - organizationId: string, + organizationId: OrganizationId, toMessage: (bots: ReadonlyArray) => Message, ) => liveQueryStream< - { userId: string; name: string; description?: string | null; user?: { avatarUrl?: string | null } }, + { userId: UserId; name: string; description?: string | null; user?: { avatarUrl?: string | null } }, Message >( (q) => @@ -66,7 +69,11 @@ export const mentionableBotsStream = ( .innerJoin({ bot: botCollection }, ({ installation, bot }) => eq(installation.botId, bot.id)) .innerJoin({ user: userCollection }, ({ bot, user }) => eq(bot.userId, user.id)) .where(({ installation, bot }) => - and(eq(installation.organizationId, organizationId), eq(bot.mentionable, true), isNull(bot.deletedAt)), + and( + eq(installation.organizationId, organizationId), + eq(bot.mentionable, true), + isNull(bot.deletedAt), + ), ) .select(({ bot, user }) => ({ ...bot, user })), (rows) => @@ -81,7 +88,7 @@ export const mentionableBotsStream = ( ) interface BotCommandRow { - readonly id: string + readonly id: BotCommandId readonly name: string readonly description: string readonly arguments: ReadonlyArray<{ @@ -91,14 +98,14 @@ interface BotCommandRow { readonly placeholder?: string | null readonly type: string }> | null - readonly botId: string + readonly botId: BotId readonly botName: string readonly avatarUrl?: string | null } /** `useBotCommands`: enabled commands of the organization's installed bots. */ export const botCommandsStream = ( - organizationId: string, + organizationId: OrganizationId, toMessage: (commands: ReadonlyArray) => Message, ) => liveQueryStream( @@ -111,7 +118,11 @@ export const botCommandsStream = ( ) .innerJoin({ user: userCollection }, ({ bot, user }) => eq(bot.userId, user.id)) .where(({ command, installation, bot }) => - and(eq(installation.organizationId, organizationId), eq(command.isEnabled, true), isNull(bot.deletedAt)), + and( + eq(installation.organizationId, organizationId), + eq(command.isEnabled, true), + isNull(bot.deletedAt), + ), ) .select(({ command, bot, user }) => ({ id: command.id, @@ -143,8 +154,8 @@ export const botCommandsStream = ( /** `ComposerEditor`'s own membership query: the signed-in user's member row in a channel. */ export const ownMembershipStream = ( - channelId: string, - userId: string, + channelId: ChannelId, + userId: UserId, toMessage: (memberId: ChannelMemberId | null) => Message, ) => liveQueryStream<{ id: ChannelMemberId }, Message>( diff --git a/apps/web-foldkit/src/composer/draft-update.ts b/apps/web-foldkit/src/composer/draft-update.ts index 50a38a3a1..295b7f28c 100644 --- a/apps/web-foldkit/src/composer/draft-update.ts +++ b/apps/web-foldkit/src/composer/draft-update.ts @@ -1,85 +1,111 @@ import type { MessageId } from "@hazel/schema" -import { Match } from "effect" -import { Command, type Update } from "foldkit" -import { errorToast } from "../data/actions" -import type { HazelRpc } from "../rpc" +import { Match, Option } from "effect" +import { Update } from "foldkit" +import { modifyFields } from "foldkit/struct" +import { failureMessage, type UserErrorMessage } from "~/lib/error-messages" +import { uploadErrorMessages } from "~/lib/upload-to-storage" +import { errorToast, errorToastOf, successToast, warningToast } from "../data/actions" +import type { EditorRegistry } from "../editor/registry" +import { isAcceptedFile } from "../editor/file-types" +import type { ToastRequest } from "../overlay/toasts" +import type { HazelApiClient, HazelRpc } from "../rpc" import * as Composer from "./composer" import * as EditorCommands from "./editor-commands" import { + type BotCommandFailure, + ComposeMode, type Context, EditMessage, + editingMessageIdOf, + ExecuteBotCommand, + fileInputId, GenerateUploadFileId, isUploading, Message, type Model, OpenFilePicker, OutMessage, + replyToMessageIdOf, + type SendFailure, SendMessage, - ExecuteBotCommand, - fileInputId, - TrackEmojiUsage, } from "./draft" import * as EmojiDialog from "../emoji-picker/dialog" import * as GifPicker from "../gif-picker/picker" import * as ComposerUpdate from "./update" import * as Typing from "./typing" import { DropEvent } from "./drop" -import { UploadEvent } from "./upload" +import { MAX_FILE_SIZE, type UploadFailure, UploadEvent } from "./upload" /** The draft's update: `ComposerEditor`'s submit, edit and escape handling plus the upload queue. */ -export type Return = Update.ReturnWithOutMessage - -const editorIdOf = (model: Model) => model.composer.editorId +type Requirements = HazelRpc | HazelApiClient | EditorRegistry +export type Return = Update.ReturnWithOutMessage +type Step = Update.Step +/** A result that raises nothing to the page. */ +type PlainReturn = Update.Return -const toComposerCommands = (commands: ReadonlyArray>) => - Command.mapMessages(commands, (message) => Message.GotComposerMessage({ message })) +// CHILDREN -const liftComposer = (model: Model, result: Update.Return): Return => ({ - model: { ...model, composer: result.model }, - commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotComposerMessage({ message })), +/** Runs a composer step (its update, `setDraft`, `cancelCommand` …) inside the draft. */ +const foldComposer = Update.foldChild({ + update: (composer: Composer.Model, step: (composer: Composer.Model) => ComposerUpdate.Return) => + step(composer), + read: (model: Model) => Option.some(model.composer), + write: (model: Model, composer: Composer.Model) => modifyFields(model, { composer: () => composer }), + toParentMessage: (message: Composer.Message) => Message.GotComposerMessage({ message }), }) -const liftTyping = (model: Model, result: Typing.Return): Return => ({ - model: { ...model, typing: result.model }, - commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotTypingMessage({ message })), +const foldTyping = Update.foldChild({ + update: (typing: Typing.Model, step: (typing: Typing.Model) => Typing.Return) => step(typing), + read: (model: Model) => Option.some(model.typing), + write: (model: Model, typing: Typing.Model) => modifyFields(model, { typing: () => typing }), + toParentMessage: (message: Typing.Message) => Message.GotTypingMessage({ message }), }) -const withCommands = (result: Return, commands: ReadonlyArray>): Return => ({ - ...result, - commands: [...(result.commands ?? []), ...commands], -}) +const setDraft = (markdown: string): Step => + foldComposer((composer) => ComposerUpdate.setDraft(composer, markdown)) +const clearDraft: Step = foldComposer(ComposerUpdate.clearDraft) +const stopTyping: Step = foldTyping(Typing.stop) + +const setMode = + (composeMode: ComposeMode): Step => + (model) => ({ model: modifyFields(model, { composeMode: () => composeMode }) }) -const toast = (model: Model, request: Parameters[0]["toast"]): Return => ({ +const toast = (model: Model, request: ToastRequest): Return => ({ model, outMessage: OutMessage.RequestedToast({ toast: request }), }) -/** `setReplyToMessageId`: entering reply clears edit. */ -export const setReply = (model: Model, replyToMessageId: MessageId | null): Model => ({ - ...model, - replyToMessageId, - editingMessageId: replyToMessageId === null ? model.editingMessageId : null, -}) +// COMPOSE MODE + +/** `setReplyToMessageId`: entering reply leaves edit mode; clearing it leaves an edit alone. */ +export const setReply = (model: Model, replyToMessageId: MessageId | null): Model => + modifyFields(model, { + composeMode: (mode) => + replyToMessageId !== null + ? ComposeMode.Replying({ messageId: replyToMessageId }) + : mode._tag === "Replying" + ? ComposeMode.Plain() + : mode, + }) /** `setEditingMessageId` plus `onEditingMessageIdChange`: load the message into the editor. */ -export const startEditing = (model: Model, messageId: MessageId, content: string): Return => ({ - model: { ...model, editingMessageId: messageId, replyToMessageId: null }, - commands: toComposerCommands([EditorCommands.SetEditorContent({ editorId: editorIdOf(model), markdown: content })]), -}) +export const startEditing = (model: Model, messageId: MessageId, content: string): Return => + Update.combine(model, [setMode(ComposeMode.Editing({ messageId })), setDraft(content)]) -const clearEditor = (model: Model) => toComposerCommands([EditorCommands.ClearEditor({ editorId: editorIdOf(model) })]) +const leaveEditing: Step = Update.combine([setMode(ComposeMode.Plain()), clearDraft]) /** `handleSubmit` of the editor and of `ComposerEditor`. */ const submit = (model: Model, markdown: string, context: Context): Return => { if (isUploading(model)) return { model } const content = markdown.trim() - if (model.editingMessageId !== null) { + const editingId = editingMessageIdOf(model) + if (editingId !== null) { if (!content) return { model } - return { - model: { ...model, editingMessageId: null }, - commands: [...clearEditor(model), EditMessage({ messageId: model.editingMessageId, content })], - } + return Update.combine(model, [ + leaveEditing, + (next) => ({ model: next, commands: [EditMessage({ messageId: editingId, content })] }), + ]) } if (!content && model.attachmentIds.length === 0) return { model } if (context.currentUserId === null) return { model } @@ -87,27 +113,48 @@ const submit = (model: Model, markdown: string, context: Context): Return => { channelId: model.channelId, authorId: context.currentUserId, content, - replyToMessageId: model.replyToMessageId, + replyToMessageId: replyToMessageIdOf(model), threadChannelId: null, attachmentIds: model.attachmentIds, restoreContent: true, }) - const cleared: Model = { ...model, replyToMessageId: null, attachmentIds: [] } - return withCommands(liftTyping(cleared, Typing.stop(cleared.typing)), [...clearEditor(model), send]) + return Update.combine(model, [ + (next) => ({ + model: modifyFields(next, { composeMode: () => ComposeMode.Plain(), attachmentIds: () => [] }), + }), + stopTyping, + clearDraft, + (next) => ({ model: next, commands: [send] }), + ]) } -/** `handleCommandExecute`: validate the arguments, then run the bot command. */ +// BOT COMMANDS + +const botCommandMessage = (reason: BotCommandFailure, commandName: string, botName: string) => + failureMessage(reason, { + BotNotFound: { title: `Bot "${botName}" was not found` }, + BotNotInstalled: { title: `Bot "${botName}" is not installed` }, + CommandNotFound: { title: `Command /${commandName} not found` }, + ExecutionFailed: { + title: `/${commandName} failed`, + description: "The bot reported an error running the command.", + }, + }) + +/** `handleCommandExecute`: validate the arguments, then run the bot command (once at a time). */ const executeCommand = (model: Model, context: Context): Return => { const input = model.composer.commandInput - if (input === null) return { model } - const error = (title: string) => toast(model, errorToast(title)) - if (context.organizationId === null) return error("Cannot execute command without an organization") + if (input === null || input.submission === "Executing") return { model } + if (context.organizationId === null) + return toast(model, errorToast("Cannot execute command without an organization")) const missing = input.command.arguments .filter((arg) => arg.required && !input.values[arg.name]) .map((arg) => arg.name) - if (missing.length > 0) return error(`Missing required: ${missing.join(", ")}`) + if (missing.length > 0) return toast(model, errorToast(`Missing required: ${missing.join(", ")}`)) return { - model, + model: modifyFields(model, { + composer: (composer) => ComposerUpdate.setCommandSubmission(composer, "Executing"), + }), commands: [ ExecuteBotCommand({ orgId: context.organizationId, @@ -123,59 +170,166 @@ const executeCommand = (model: Model, context: Context): Return => { } } +// UPLOADS + +const uploadMessage = (reason: UploadFailure) => + failureMessage(reason, { + FileTooLarge: { + title: "File too large", + description: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`, + }, + Rejected: { title: "Upload failed", description: "Failed to get upload URL. Please try again." }, + StorageNetwork: { title: "Upload failed", description: uploadErrorMessages.network }, + StorageTimeout: { title: "Upload failed", description: uploadErrorMessages.timeout }, + StorageServer: { title: "Upload failed", description: uploadErrorMessages.server }, + NotCompleted: { title: "Upload failed", description: "Failed to finalize upload. Please try again." }, + }) + /** The next queued file starts once it has an id. */ -const startNextUpload = (model: Model): Return => +const startNextUpload = (model: Model): PlainReturn => model.currentUpload !== null || model.pendingFiles.length === 0 ? { model } : { model, commands: [GenerateUploadFileId()] } -const handleUploadEvent = (model: Model, event: UploadEvent): Return => - UploadEvent.match(event, { - ProgressedUpload: ({ fileId, progress }) => ({ - model: { - ...model, - uploadingFiles: model.uploadingFiles.map((file) => (file.fileId === fileId ? { ...file, progress } : file)), - }, +/** The current upload ended: the next one starts. */ +const finishUpload = (model: Model, attachmentIds: Model["attachmentIds"]): PlainReturn => + startNextUpload(modifyFields(model, { currentUpload: () => null, attachmentIds: () => attachmentIds })) + +const handleUploadEvent = (model: Model, event: UploadEvent): Return => { + // A late event of an upload that is no longer current changes nothing. + if (model.currentUpload?.fileId !== event.fileId) return { model } + return UploadEvent.match(event, { + ProgressedUpload: ({ progress }) => ({ + model: modifyFields(model, { + currentUpload: (upload) => + upload === null ? null : modifyFields(upload, { progress: () => progress }), + }), }), - FinishedUpload: ({ fileId, attachmentId, toast: request }) => { - if (model.currentUpload?.fileId !== fileId) return { model } - const next = startNextUpload({ - ...model, - currentUpload: null, - uploadingFiles: model.uploadingFiles.filter((file) => file.fileId !== fileId), - attachmentIds: attachmentId === null ? model.attachmentIds : [...model.attachmentIds, attachmentId], - }) - return request === null ? next : { ...next, outMessage: OutMessage.RequestedToast({ toast: request }) } + SucceededUpload: ({ attachmentId }) => finishUpload(model, [...model.attachmentIds, attachmentId]), + FailedUpload: ({ reason }) => + Update.withOutMessage( + finishUpload(model, model.attachmentIds), + OutMessage.RequestedToast({ toast: errorToastOf(uploadMessage(reason)) }), + ), + }) +} + +const selectFiles = (model: Model, files: ReadonlyArray, context: Context): Return => { + if (files.length === 0) return { model } + if (context.currentUserId === null) + return toast(model, errorToast("Authentication required", "You must be logged in to upload files")) + const accepted = files.filter(isAcceptedFile) + if (accepted.length === 0) return toast(model, errorToast("Unsupported file type")) + return startNextUpload(modifyFields(model, { pendingFiles: (pending) => [...pending, ...accepted] })) +} + +// SEND + +const sendFailureMessage = (failure: SendFailure) => + failureMessage(failure, { + ChannelNotFound: { title: "Channel not found", description: "This channel may have been deleted." }, + SyncDelayed: { + title: "Message sent", + description: "Sync is delayed but your message was delivered.", }, }) +const warningToastOf = (message: UserErrorMessage): ToastRequest => + warningToast(message.title, message.description ?? null) + +// PICKERS + +/** `ComposerActions.handleEmojiSelect`: custom emoji inline, unicode as text. */ +const insertEmoji = + (emoji: string, label: string, imageUrl: string | null): Step => + (model) => + foldComposer(model, (composer) => ({ + model: composer, + commands: [ + imageUrl === null + ? EditorCommands.InsertEditorText({ editorId: composer.editorId, text: emoji }) + : EditorCommands.InsertCustomEmoji({ + editorId: composer.editorId, + name: label, + imageUrl, + }), + ], + })) + +/** `handleGifSelect` sends the GIF URL as a message of its own; it answers a reply, an edit stays. */ +const sendGif = + (url: string, context: Context): Step => + (model) => + context.currentUserId === null + ? { model } + : { + model: modifyFields(setReply(model, null), { attachmentIds: () => [] }), + commands: [ + SendMessage({ + channelId: model.channelId, + authorId: context.currentUserId, + content: url, + replyToMessageId: replyToMessageIdOf(model), + threadChannelId: null, + attachmentIds: model.attachmentIds, + restoreContent: false, + }), + ], + } + +const foldEmojiPicker = Update.foldChild({ + update: EmojiDialog.update, + read: (model: Model) => Option.some(model.emojiPicker), + write: (model: Model, emojiPicker: EmojiDialog.Model) => + modifyFields(model, { emojiPicker: () => emojiPicker }), + toParentMessage: (message: EmojiDialog.Message) => Message.GotEmojiPickerMessage({ message }), + foldOutMessage: (selected: EmojiDialog.OutMessage) => + insertEmoji(selected.emoji, selected.label, selected.imageUrl), +}) + +const foldGifPicker = (context: Context) => + Update.foldChild({ + update: GifPicker.update, + read: (model: Model) => Option.some(model.gifPicker), + write: (model: Model, gifPicker: GifPicker.Model) => + modifyFields(model, { gifPicker: () => gifPicker }), + toParentMessage: (message: GifPicker.Message) => Message.GotGifPickerMessage({ message }), + foldOutMessage: (selected: GifPicker.OutMessage) => sendGif(selected.url, context), + }) + +// UPDATE + const handleComposer = (model: Model, message: Composer.Message, context: Context): Return => { - const delegated = liftComposer(model, ComposerUpdate.update(model.composer, message)) + const delegated = foldComposer(model, (composer) => ComposerUpdate.update(composer, message)) + const then = (step: (model: Model) => Return): Return => { + const next = step(delegated.model) + return { ...next, commands: [...(delegated.commands ?? []), ...(next.commands ?? [])] } + } return Match.value(message).pipe( + Match.withReturnType(), Match.tags({ UpdatedDraft: ({ markdown }) => - withCommands( - liftTyping( - delegated.model, - Typing.contentChanged(delegated.model.typing, markdown, { + then((next) => + foldTyping(next, (typing) => + Typing.contentChanged(typing, markdown, { channelId: model.channelId, memberId: context.memberId, }), ), - delegated.commands ?? [], ), - SubmittedDraft: ({ markdown }) => submit(model, markdown, context), + SubmittedDraft: ({ markdown }) => then((next) => submit(next, markdown, context)), PressedEscape: () => - model.editingMessageId === null - ? { model } - : { model: { ...model, editingMessageId: null }, commands: clearEditor(model) }, + then((next) => (editingMessageIdOf(next) === null ? { model: next } : leaveEditing(next))), PressedArrowUpInEmpty: () => - context.lastOwnMessage === null - ? { model } - : startEditing(model, context.lastOwnMessage.id, context.lastOwnMessage.content), - PastedFiles: ({ files }) => update(model, Message.SelectedFiles({ files }), context), - ClickedExecuteCommand: () => executeCommand(model, context), - PressedCommandFieldKey: ({ key }) => (key === "Enter" ? executeCommand(model, context) : delegated), + then((next) => + context.lastOwnMessage === null + ? { model: next } + : startEditing(next, context.lastOwnMessage.id, context.lastOwnMessage.content), + ), + PastedFiles: ({ files }) => then((next) => selectFiles(next, files, context)), + ClickedExecuteCommand: () => then((next) => executeCommand(next, context)), + PressedCommandFieldKey: ({ key }) => + key === "Enter" ? then((next) => executeCommand(next, context)) : delegated, }), Match.orElse(() => delegated), ) @@ -184,110 +338,81 @@ const handleComposer = (model: Model, message: Composer.Message, context: Contex export const update = (model: Model, message: Message, context: Context): Return => Message.match(message, { GotComposerMessage: ({ message: composerMessage }) => handleComposer(model, composerMessage, context), - GotTypingMessage: ({ message: typingMessage }) => liftTyping(model, Typing.update(model.typing, typingMessage)), + GotTypingMessage: ({ message: typingMessage }) => + foldTyping(model, (typing) => Typing.update(typing, typingMessage)), ClickedCancelReply: () => ({ model: setReply(model, null) }), // `ComposerEditIndicator.handleCancel`: leave edit mode and clear the editor. - ClickedCancelEdit: () => ({ model: { ...model, editingMessageId: null }, commands: clearEditor(model) }), + ClickedCancelEdit: () => leaveEditing(model), ClickedAttach: () => - isUploading(model) ? { model } : { model, commands: [OpenFilePicker({ inputId: fileInputId(model) })] }, - SelectedFiles: ({ files }) => { - if (files.length === 0) return { model } - if (context.currentUserId === null) - return toast(model, errorToast("Authentication required", "You must be logged in to upload files")) - return startNextUpload({ ...model, pendingFiles: [...model.pendingFiles, ...files] }) - }, + isUploading(model) + ? { model } + : { model, commands: [OpenFilePicker({ inputId: fileInputId(model) })] }, + SelectedFiles: ({ files }) => selectFiles(model, files, context), CompletedGenerateUploadFileId: ({ fileId }) => { const [file, ...rest] = model.pendingFiles if (file === undefined || model.currentUpload !== null) return { model } return { - model: { - ...model, - pendingFiles: rest, - currentUpload: { fileId, file }, - uploadingFiles: [ - ...model.uploadingFiles, - { fileId, fileName: file.name, fileSize: file.size, progress: 0 }, - ], - }, + model: modifyFields(model, { + pendingFiles: () => rest, + currentUpload: () => ({ fileId, file, progress: 0 }), + }), } }, ReceivedUploadEvent: ({ event }) => handleUploadEvent(model, event), ReceivedDropEvent: ({ event }) => DropEvent.match(event, { - ChangedDragState: ({ isDraggingOnPage, isDropTarget }) => ({ - model: { ...model, isDraggingOnPage, isDropTarget }, - }), - DroppedFiles: ({ files }) => update(model, Message.SelectedFiles({ files }), context), + ChangedDragState: ({ drag }) => ({ model: modifyFields(model, { drag: () => drag }) }), + // Legacy disables the drop zone while a file uploads. + DroppedFiles: ({ files }) => + isUploading(model) ? { model } : selectFiles(model, files, context), }), ClickedRemoveAttachment: ({ attachmentId }) => ({ - model: { ...model, attachmentIds: model.attachmentIds.filter((id) => id !== attachmentId) }, + model: modifyFields(model, { attachmentIds: (ids) => ids.filter((id) => id !== attachmentId) }), }), CompletedOpenFilePicker: () => ({ model }), SucceededSendMessage: () => ({ model, outMessage: OutMessage.SentMessage() }), - // Restore what the optimistic send cleared, then toast. - FailedSendMessage: ({ content, restoreContent, replyToMessageId, attachmentIds, toast: request }) => ({ - model: { ...setReply(model, replyToMessageId), attachmentIds }, - commands: restoreContent - ? toComposerCommands([EditorCommands.SetEditorContent({ editorId: editorIdOf(model), markdown: content })]) - : [], - outMessage: OutMessage.RequestedToast({ toast: request }), - }), - CompletedEditMessage: ({ toast: request }) => (request === null ? { model } : toast(model, request)), - LeftWindow: () => liftTyping(model, Typing.stop(model.typing)), - // Success leaves command input mode and focuses the editor; failure keeps the panel. - CompletedExecuteBotCommand: ({ succeeded, toast: request }) => { - const result = succeeded ? liftComposer(model, ComposerUpdate.cancelCommand(model.composer)) : { model } - return { ...result, outMessage: OutMessage.RequestedToast({ toast: request }) } - }, - // `ComposerActions.handleEmojiSelect`: custom emoji inline, unicode as text. - SelectedEmoji: ({ emoji, label, imageUrl }) => ({ - model, - commands: toComposerCommands([ - imageUrl === null - ? EditorCommands.InsertEditorText({ editorId: editorIdOf(model), text: emoji }) - : EditorCommands.InsertCustomEmoji({ editorId: editorIdOf(model), name: label, imageUrl }), - ]), - }), - GotEmojiPickerMessage: ({ message: dialogMessage }) => { - const result = EmojiDialog.update(model.emojiPicker, dialogMessage) - const next: Return = { - model: { ...model, emojiPicker: result.model }, - commands: Command.mapMessages(result.commands ?? [], (inner) => - Message.GotEmojiPickerMessage({ message: inner }), - ), - } - if (result.outMessage === undefined) return next - const { emoji, label, imageUrl } = result.outMessage - const inserted = update(next.model, Message.SelectedEmoji({ emoji, label, imageUrl }), context) - return { ...inserted, commands: [...(next.commands ?? []), TrackEmojiUsage({ emoji }), ...(inserted.commands ?? [])] } - }, - CompletedTrackEmojiUsage: () => ({ model }), - GotGifPickerMessage: ({ message: gifMessage }) => { - const result = GifPicker.update(model.gifPicker, gifMessage) - const next: Return = { - model: { ...model, gifPicker: result.model }, - commands: Command.mapMessages(result.commands ?? [], (inner) => Message.GotGifPickerMessage({ message: inner })), - } - if (result.outMessage === undefined) return next - const sent = update(next.model, Message.SelectedGif({ url: result.outMessage.url }), context) - return { ...sent, commands: [...(next.commands ?? []), ...(sent.commands ?? [])] } - }, - // `handleGifSelect` sends the GIF URL as a message of its own. - SelectedGif: ({ url }) => - context.currentUserId === null - ? { model } - : { - model: { ...model, replyToMessageId: null, attachmentIds: [] }, - commands: [ - SendMessage({ - channelId: model.channelId, - authorId: context.currentUserId, - content: url, - replyToMessageId: model.replyToMessageId, - threadChannelId: null, - attachmentIds: model.attachmentIds, - restoreContent: false, + // Restore what the optimistic send cleared, then toast. A delayed sync was delivered: nothing to restore. + FailedSendMessage: ({ content, restoreContent, replyToMessageId, attachmentIds, reason }) => + reason === "SyncDelayed" + ? toast(model, warningToastOf(sendFailureMessage(reason))) + : Update.withOutMessage( + Update.combine( + modifyFields(setReply(model, replyToMessageId), { + attachmentIds: () => attachmentIds, }), - ], - }, + restoreContent ? [setDraft(content)] : [], + ), + OutMessage.RequestedToast({ toast: errorToastOf(sendFailureMessage(reason)) }), + ), + SucceededEditMessage: () => ({ model }), + FailedEditMessage: ({ reason }) => + toast( + model, + errorToastOf( + failureMessage(reason, { + MessageNotFound: { + title: "Message not found", + description: "This message may have been deleted.", + }, + }), + ), + ), + LeftWindow: () => stopTyping(model), + // Success leaves command input mode and focuses the editor; failure keeps the panel. + SucceededExecuteBotCommand: ({ commandName }) => + Update.withOutMessage( + foldComposer(model, ComposerUpdate.cancelCommand), + OutMessage.RequestedToast({ toast: successToast(`Executed /${commandName}`) }), + ), + FailedExecuteBotCommand: ({ commandName, botName, reason }) => + toast( + modifyFields(model, { + composer: (composer) => ComposerUpdate.setCommandSubmission(composer, "Idle"), + }), + errorToastOf(botCommandMessage(reason, commandName, botName)), + ), + SelectedEmoji: ({ emoji, label, imageUrl }) => insertEmoji(emoji, label, imageUrl)(model), + GotEmojiPickerMessage: ({ message: dialogMessage }) => foldEmojiPicker(model, dialogMessage), + GotGifPickerMessage: ({ message: gifMessage }) => foldGifPicker(context)(model, gifMessage), + SelectedGif: ({ url }) => sendGif(url, context)(model), }) diff --git a/apps/web-foldkit/src/composer/draft-view.ts b/apps/web-foldkit/src/composer/draft-view.ts index 26812b998..39bf6e6c7 100644 --- a/apps/web-foldkit/src/composer/draft-view.ts +++ b/apps/web-foldkit/src/composer/draft-view.ts @@ -10,7 +10,7 @@ import { loader } from "../ui/loader" import { fileInputId, hasTopContent, isUploading, Message, type Model } from "./draft" import * as EmojiDialog from "../emoji-picker/dialog" import * as GifPickerView from "../gif-picker/view" -import { TrackFileDrop } from "./drop" +import { DragState, TrackFileDrop } from "./drop" import { composerBoxView } from "./view" /** A draft's composer: `SlateMessageComposer` with previews, indicators, the drop zone and the actions. */ @@ -24,25 +24,28 @@ export interface ReplyPreview { export interface DraftViewInputs { readonly toMessage: (message: Message) => M readonly replyPreview: ReplyPreview | null - readonly attachmentInfo: (id: AttachmentId) => { readonly fileName: string; readonly fileSize: number } | null + readonly attachmentInfo: ( + id: AttachmentId, + ) => { readonly fileName: string; readonly fileSize: number } | null } -const removeButton = (h: HtmlBuilder, onPress: M, label?: string) => +const removeButton = (h: HtmlBuilder, onPress: M, label: string, isCompact = false) => button( h, { intent: "plain", size: "sq-xs", - ...(label === undefined ? {} : { className: "!p-1" }), + ...(isCompact ? { className: "!p-1" } : {}), onPress, - attributes: label === undefined ? [] : [h.Attribute("aria-label", label)], + attributes: [h.AriaLabel(label)], }, - [IconClose(h, { ...(label === undefined ? {} : { className: "size-3.5" }), attributes: { "data-slot": "icon" } })], + [IconClose(h, { ...(isCompact ? { className: "size-3.5" } : {}), attributes: { "data-slot": "icon" } })], ) /** `ComposerAttachmentPreviews`. */ const attachmentPreviews = (h: HtmlBuilder, model: Model, inputs: DraftViewInputs): Html => { - if (model.attachmentIds.length === 0 && model.uploadingFiles.length === 0) return h.empty + const uploading = model.currentUpload === null ? [] : [model.currentUpload] + if (model.attachmentIds.length === 0 && uploading.length === 0) return h.empty const card = (key: string, className: string, children: ReadonlyArray) => h.keyed("div")(key, [h.Class(className)], children) const fileText = (name: string, detail: string) => @@ -58,8 +61,8 @@ const attachmentPreviews = (h: HtmlBuilder, model: Model, inputs: DraftVie h.Class( cn( "border border-border border-b-0 bg-secondary px-2 py-1", - model.uploadingFiles.length > 0 ? "rounded-t-none border-t-0" : "rounded-t-lg", - model.replyToMessageId !== null && "border-b-0", + uploading.length > 0 ? "rounded-t-none border-t-0" : "rounded-t-lg", + model.composeMode._tag === "Replying" && "border-b-0", ), ), ], @@ -74,23 +77,27 @@ const attachmentPreviews = (h: HtmlBuilder, model: Model, inputs: DraftVie attachmentId, "group flex items-center gap-2 rounded-lg bg-bg p-2 transition-colors hover:bg-secondary", [ - fileIcon(h, getFileTypeFromName(fileName), { className: "size-8 shrink-0 text-muted-fg" }), + fileIcon(h, getFileTypeFromName(fileName), { + className: "size-8 shrink-0 text-muted-fg", + }), fileText(fileName, formatFileSize(info?.fileSize || 0)), - removeButton(h, inputs.toMessage(Message.ClickedRemoveAttachment({ attachmentId }))), + removeButton(h, inputs.toMessage(Message.ClickedRemoveAttachment({ attachmentId })), `Remove ${fileName}`), ], ) }), - ...model.uploadingFiles.map((file) => + ...uploading.map((upload) => card( - file.fileId, + upload.fileId, "group relative flex items-center gap-2 overflow-hidden rounded-lg bg-bg p-2 transition-colors hover:bg-secondary", [ - fileIcon(h, getFileTypeFromName(file.fileName), { className: "size-8 shrink-0 text-muted-fg" }), + fileIcon(h, getFileTypeFromName(upload.file.name), { + className: "size-8 shrink-0 text-muted-fg", + }), fileText( - file.fileName, - file.progress < 100 - ? `${file.progress}% of ${formatFileSize(file.fileSize)}` - : formatFileSize(file.fileSize), + upload.file.name, + upload.progress < 100 + ? `${upload.progress}% of ${formatFileSize(upload.file.size)}` + : formatFileSize(upload.file.size), ), loader(h, { className: "size-4" }), h.div( @@ -98,7 +105,7 @@ const attachmentPreviews = (h: HtmlBuilder, model: Model, inputs: DraftVie [ h.div([ h.Class("h-full bg-primary transition-all duration-200"), - h.Attribute("style", `width: ${file.progress}%;`), + h.Attribute("style", `width: ${upload.progress}%;`), ]), ], ), @@ -111,13 +118,14 @@ const attachmentPreviews = (h: HtmlBuilder, model: Model, inputs: DraftVie ) } -const indicatorClass = "flex items-center justify-between gap-2 rounded-t-lg border border-border border-b-0 bg-secondary px-3 py-2" +const indicatorClass = + "flex items-center justify-between gap-2 rounded-t-lg border border-border border-b-0 bg-secondary px-3 py-2" /** `ComposerReplyIndicator` (`ReplyIndicator`). */ const replyIndicator = (h: HtmlBuilder, model: Model, inputs: DraftViewInputs): Html => { const reply = inputs.replyPreview - if (model.replyToMessageId === null || reply === null) return h.empty - const hasFiles = model.uploadingFiles.length > 0 || model.attachmentIds.length > 0 + if (model.composeMode._tag !== "Replying" || reply === null) return h.empty + const hasFiles = model.currentUpload !== null || model.attachmentIds.length > 0 return h.div( [h.Class(cn(indicatorClass, hasFiles ? "rounded-t-none border-t-0" : ""))], [ @@ -129,15 +137,15 @@ const replyIndicator = (h: HtmlBuilder, model: Model, inputs: DraftViewInp h.span([h.Class("max-w-xs truncate text-muted-fg")], [reply.firstLine]), ], ), - removeButton(h, inputs.toMessage(Message.ClickedCancelReply()), "Cancel reply"), + removeButton(h, inputs.toMessage(Message.ClickedCancelReply()), "Cancel reply", true), ], ) } /** `ComposerEditIndicator`. */ const editIndicator = (h: HtmlBuilder, model: Model, inputs: DraftViewInputs): Html => { - if (model.editingMessageId === null) return h.empty - const hasFiles = model.uploadingFiles.length > 0 || model.attachmentIds.length > 0 + if (model.composeMode._tag !== "Editing") return h.empty + const hasFiles = model.currentUpload !== null || model.attachmentIds.length > 0 return h.div( [h.Class(cn(indicatorClass, hasFiles ? "rounded-t-none border-t-0" : ""))], [ @@ -148,32 +156,36 @@ const editIndicator = (h: HtmlBuilder, model: Model, inputs: DraftViewInpu h.span([h.Class("font-semibold text-primary")], ["Editing message"]), ], ), - removeButton(h, inputs.toMessage(Message.ClickedCancelEdit()), "Cancel editing"), + removeButton(h, inputs.toMessage(Message.ClickedCancelEdit()), "Cancel editing", true), ], ) } -/** The drop zone's overlays: strong over the zone, subtle while dragging anywhere. */ +/** The drop zone's overlays: strong over the zone, subtle while dragging anywhere; none while a file uploads. */ const dropOverlay = (h: HtmlBuilder, model: Model): Html => - model.isDropTarget - ? h.div( - [ - h.Class( - "absolute inset-0 z-20 flex items-center justify-center rounded-xl border-2 border-primary border-dashed bg-primary/10", + isUploading(model) + ? h.empty + : DragState.match(model.drag, { + Idle: () => h.empty, + OverDropZone: () => + h.div( + [ + h.Class( + "absolute inset-0 z-20 flex items-center justify-center rounded-xl border-2 border-primary border-dashed bg-primary/10", + ), + ], + [h.span([h.Class("font-medium text-primary")], ["Drop files here"])], ), - ], - [h.span([h.Class("font-medium text-primary")], ["Drop files here"])], - ) - : model.isDraggingOnPage - ? h.div( - [ - h.Class( - "absolute inset-0 z-20 flex items-center justify-center rounded-xl border-2 border-primary/50 border-dashed bg-primary/5", - ), - ], - [h.span([h.Class("font-medium text-primary/70")], ["Drop files here"])], - ) - : h.empty + DraggingOnPage: () => + h.div( + [ + h.Class( + "absolute inset-0 z-20 flex items-center justify-center rounded-xl border-2 border-primary/50 border-dashed bg-primary/5", + ), + ], + [h.span([h.Class("font-medium text-primary/70")], ["Drop files here"])], + ), + }) export const draftView = (h: HtmlBuilder, model: Model, inputs: DraftViewInputs): Html => { const { toMessage } = inputs @@ -189,9 +201,7 @@ export const draftView = (h: HtmlBuilder, model: Model, inputs: DraftViewI dropOverlay: dropOverlay(h, model), dropZoneAttributes: [ h.OnMount( - Mount.mapMessage(TrackFileDrop({ isDisabled: uploading }), (event) => - toMessage(Message.ReceivedDropEvent({ event })), - ), + Mount.mapMessage(TrackFileDrop(), (event) => toMessage(Message.ReceivedDropEvent({ event }))), ), ], fileInputAttributes: [ diff --git a/apps/web-foldkit/src/composer/draft.story.test.ts b/apps/web-foldkit/src/composer/draft.story.test.ts index 3fb9f424a..67c320b10 100644 --- a/apps/web-foldkit/src/composer/draft.story.test.ts +++ b/apps/web-foldkit/src/composer/draft.story.test.ts @@ -1,22 +1,33 @@ -import { AttachmentId, ChannelId, ChannelMemberId, MessageId, OrganizationId, TypingIndicatorId, UserId } from "@hazel/schema" +import { + AttachmentId, + BotCommandId, + BotId, + ChannelId, + ChannelMemberId, + MessageId, + OrganizationId, + TypingIndicatorId, + UserId, +} from "@hazel/schema" import { Schema } from "effect" import { Command, expectOutMessage, given, message, model, story } from "foldkit/story" import { describe, expect, test, vi } from "vitest" import * as Composer from "./composer" import * as Draft from "./draft" import * as DraftUpdate from "./draft-update" -import { DropEvent } from "./drop" +import { DragState, DropEvent } from "./drop" import * as EditorCommands from "./editor-commands" import * as Typing from "./typing" import { UploadEvent } from "./upload" -import type { ToastRequest } from "../overlay/toasts" /** One draft's update (reply, edit, uploads, slash commands, typing), outside any page. */ // ProseMirror's view reads `document` at import; node has none. vi.hoisted(() => { if (!("document" in globalThis)) - Object.assign(globalThis, { document: Object.assign(new EventTarget(), { documentElement: { style: {} } }) }) + Object.assign(globalThis, { + document: Object.assign(new EventTarget(), { documentElement: { style: {} } }), + }) }) const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` @@ -35,59 +46,191 @@ const context: Draft.Context = { currentUserId: ada, organizationId, memberId, l const signedOut: Draft.Context = { ...context, currentUserId: null } const update = (current: Draft.Model, next: Draft.Message) => DraftUpdate.update(current, next, context) -const updateSignedOut = (current: Draft.Model, next: Draft.Message) => DraftUpdate.update(current, next, signedOut) +const updateSignedOut = (current: Draft.Model, next: Draft.Message) => + DraftUpdate.update(current, next, signedOut) const fresh = () => Draft.init(channelId, editorId) +const replying = (): Draft.Model => ({ + ...fresh(), + composeMode: Draft.ComposeMode.Replying({ messageId: replyId }), +}) +const editing = (): Draft.Model => ({ + ...fresh(), + composeMode: Draft.ComposeMode.Editing({ messageId: editId }), +}) const { Message } = Draft const composer = (inner: Composer.Message) => Message.GotComposerMessage({ message: inner }) const pngA = new File(["a"], "a.png", { type: "image/png" }) const pngB = new File(["bb"], "b.png", { type: "image/png" }) +const uploadingA = (): Draft.Model => ({ + ...fresh(), + currentUpload: { fileId: "f1", file: pngA, progress: 30 }, +}) describe("reply and edit", () => { test("Cancel on the reply indicator drops the reply", () => { story( update, - given({ ...fresh(), replyToMessageId: replyId }), + given(replying()), message(Message.ClickedCancelReply()), Command.expectNone(), - model((current) => expect(current.replyToMessageId).toBeNull()), + model((current) => expect(current.composeMode).toEqual(Draft.ComposeMode.Plain())), ) }) test("replying while editing leaves edit mode (one indicator at a time)", () => { - const replying = DraftUpdate.setReply({ ...fresh(), editingMessageId: editId }, replyId) - expect(replying.editingMessageId).toBeNull() - expect(replying.replyToMessageId).toBe(replyId) + const next = DraftUpdate.setReply(editing(), replyId) + expect(next.composeMode).toEqual(Draft.ComposeMode.Replying({ messageId: replyId })) }) - test("Cancel on the edit indicator leaves edit mode and clears the editor", () => { + test("Cancel on the edit indicator leaves edit mode and clears the draft", () => { story( update, - given({ ...fresh(), editingMessageId: editId }), + given({ ...editing(), composer: { ...fresh().composer, markdown: "old text" } }), message(Message.ClickedCancelEdit()), Command.expectExact(EditorCommands.ClearEditor({ editorId })), Command.resolve(EditorCommands.ClearEditor, Composer.Message.CompletedClearEditor()), - model((current) => expect(current.editingMessageId).toBeNull()), + model((current) => { + expect(current.composeMode).toEqual(Draft.ComposeMode.Plain()) + expect(current.composer.markdown).toBe("") + }), ) }) test("an empty edit is not sent and keeps edit mode", () => { story( update, - given({ ...fresh(), editingMessageId: editId }), + given(editing()), message(composer(Composer.Message.SubmittedDraft({ markdown: " " }))), Command.expectNone(), - model((current) => expect(current.editingMessageId).toBe(editId)), + model((current) => expect(Draft.editingMessageIdOf(current)).toBe(editId)), ) }) test("a failed edit raises its toast", () => { - const toast: ToastRequest = { intent: "error", title: "Message not found", description: null } story( update, given(fresh()), - message(Message.CompletedEditMessage({ toast })), - expectOutMessage(Draft.OutMessage.RequestedToast({ toast })), + message(Message.FailedEditMessage({ reason: "MessageNotFound" })), + expectOutMessage( + Draft.OutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Message not found", + description: "This message may have been deleted.", + }, + }), + ), + ) + }) +}) + +describe("the draft survives its editor (P0: Files tab round-trip)", () => { + test("editing loads the message into the Model, so a remounted editor shows it; Enter still edits", () => { + const content = "Shipped the onboarding copy" + const withLastOwn = (current: Draft.Model, next: Draft.Message) => + DraftUpdate.update(current, next, { ...context, lastOwnMessage: { id: editId, content } }) + story( + withLastOwn, + given(fresh()), + // Arrow Up in an empty editor starts editing the last own message. + message(composer(Composer.Message.PressedArrowUpInEmpty())), + Command.expectExact(EditorCommands.SetEditorContent({ editorId, markdown: content })), + // The Messages tab is left before the editor took the content: the Command reports it. + Command.resolve( + EditorCommands.SetEditorContent, + Composer.Message.FailedSetEditorContent({ reason: "EditorNotMounted" }), + ), + model((current) => { + expect(Draft.editingMessageIdOf(current)).toBe(editId) + // The composer view mounts the editor with this as `initialMarkdown`. + expect(current.composer.markdown).toBe(content) + }), + message(composer(Composer.Message.SubmittedDraft({ markdown: `${content}!` }))), + Command.expectExact( + EditorCommands.ClearEditor({ editorId }), + Draft.EditMessage({ messageId: editId, content: `${content}!` }), + ), + Command.resolve(EditorCommands.ClearEditor, Composer.Message.CompletedClearEditor()), + Command.resolve(Draft.EditMessage, Message.SucceededEditMessage()), + model((current) => { + expect(current.composeMode).toEqual(Draft.ComposeMode.Plain()) + expect(current.composer.markdown).toBe("") + }), + ) + }) + + test("typed text is kept in the Model as the editor reports it", () => { + story( + // No membership, so no typing indicator to send. + (current: Draft.Model, next: Draft.Message) => + DraftUpdate.update(current, next, { ...context, memberId: null }), + given(fresh()), + message(composer(Composer.Message.UpdatedDraft({ markdown: "Hi there", isEmpty: false }))), + Command.expectNone(), + model((current) => expect(current.composer.markdown).toBe("Hi there")), + ) + }) + + test("a failed send restores its text into the Model even without a mounted editor", () => { + story( + update, + given(fresh()), + message( + Message.FailedSendMessage({ + content: "On it", + restoreContent: true, + replyToMessageId: replyId, + attachmentIds: [firstAttachment], + reason: "Offline", + }), + ), + Command.expectExact(EditorCommands.SetEditorContent({ editorId, markdown: "On it" })), + expectOutMessage( + Draft.OutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Connection lost", + description: "Check your internet connection and try again.", + }, + }), + ), + Command.resolve( + EditorCommands.SetEditorContent, + Composer.Message.FailedSetEditorContent({ reason: "EditorNotMounted" }), + ), + model((current) => { + expect(current.composer.markdown).toBe("On it") + expect(current.composeMode).toEqual(Draft.ComposeMode.Replying({ messageId: replyId })) + expect(current.attachmentIds).toEqual([firstAttachment]) + }), + ) + }) + + test("a send whose sync is delayed was delivered: nothing is restored", () => { + story( + update, + given(fresh()), + message( + Message.FailedSendMessage({ + content: "On it", + restoreContent: true, + replyToMessageId: null, + attachmentIds: [], + reason: "SyncDelayed", + }), + ), + Command.expectNone(), + expectOutMessage( + Draft.OutMessage.RequestedToast({ + toast: { + intent: "warning", + title: "Message sent", + description: "Sync is delayed but your message was delivered.", + }, + }), + ), + model((current) => expect(current.composer.markdown).toBe("")), ) }) }) @@ -99,59 +242,105 @@ describe("attachments", () => { given(fresh()), message(Message.SelectedFiles({ files: [pngA, pngB] })), Command.expectExact(Draft.GenerateUploadFileId()), - Command.resolve(Draft.GenerateUploadFileId, Message.CompletedGenerateUploadFileId({ fileId: "f1" })), + Command.resolve( + Draft.GenerateUploadFileId, + Message.CompletedGenerateUploadFileId({ fileId: "f1" }), + ), model((current) => { - expect(current.currentUpload?.fileId).toBe("f1") + expect(current.currentUpload).toEqual({ fileId: "f1", file: pngA, progress: 0 }) expect(current.pendingFiles).toHaveLength(1) expect(Draft.isUploading(current)).toBe(true) }), message( Message.ReceivedUploadEvent({ - event: UploadEvent.FinishedUpload({ fileId: "f1", attachmentId: firstAttachment, toast: null }), + event: UploadEvent.ProgressedUpload({ fileId: "f1", progress: 60 }), + }), + ), + model((current) => expect(current.currentUpload?.progress).toBe(60)), + message( + Message.ReceivedUploadEvent({ + event: UploadEvent.SucceededUpload({ fileId: "f1", attachmentId: firstAttachment }), }), ), Command.expectExact(Draft.GenerateUploadFileId()), - Command.resolve(Draft.GenerateUploadFileId, Message.CompletedGenerateUploadFileId({ fileId: "f2" })), + Command.resolve( + Draft.GenerateUploadFileId, + Message.CompletedGenerateUploadFileId({ fileId: "f2" }), + ), message( Message.ReceivedUploadEvent({ - event: UploadEvent.FinishedUpload({ fileId: "f2", attachmentId: secondAttachment, toast: null }), + event: UploadEvent.SucceededUpload({ fileId: "f2", attachmentId: secondAttachment }), }), ), Command.expectNone(), model((current) => { expect(current.attachmentIds).toEqual([firstAttachment, secondAttachment]) - expect(current.uploadingFiles).toEqual([]) + expect(current.currentUpload).toBeNull() expect(Draft.isUploading(current)).toBe(false) }), ) }) - test("a failed upload toasts and adds no attachment", () => { - const toast: ToastRequest = { intent: "error", title: "Upload failed", description: null } + test("a failed upload toasts its reason's copy and adds no attachment", () => { story( update, - given({ - ...fresh(), - currentUpload: { fileId: "f1", file: pngA }, - uploadingFiles: [{ fileId: "f1", fileName: "a.png", fileSize: 1, progress: 30 }], - }), - message(Message.ReceivedUploadEvent({ event: UploadEvent.FinishedUpload({ fileId: "f1", attachmentId: null, toast }) })), - expectOutMessage(Draft.OutMessage.RequestedToast({ toast })), + given(uploadingA()), + message( + Message.ReceivedUploadEvent({ + event: UploadEvent.FailedUpload({ fileId: "f1", reason: "StorageTimeout" }), + }), + ), + expectOutMessage( + Draft.OutMessage.RequestedToast({ + toast: { + intent: "error", + title: "Upload failed", + description: "Upload timed out. Try a smaller file or check your connection.", + }, + }), + ), model((current) => { expect(current.attachmentIds).toEqual([]) - expect(current.uploadingFiles).toEqual([]) + expect(current.currentUpload).toBeNull() }), ) }) - test("while a file uploads, Enter and the Attach button do nothing", () => { + test("an event of an upload that is no longer current changes nothing", () => { + const current = uploadingA() + story( + update, + given(current), + message( + Message.ReceivedUploadEvent({ + event: UploadEvent.SucceededUpload({ fileId: "stale", attachmentId: firstAttachment }), + }), + ), + Command.expectNone(), + model((next) => expect(next).toBe(current)), + ) + }) + + test("the upload Subscription depends on the file, not on its progress", () => { + const current = uploadingA() + const progressed: Draft.Model = { + ...current, + currentUpload: { fileId: "f1", file: pngA, progress: 90 }, + } + expect(Draft.currentUploadOf(progressed)).toEqual(Draft.currentUploadOf(current)) + }) + + test("while a file uploads, Enter, the Attach button and drops do nothing", () => { story( update, - given({ ...fresh(), currentUpload: { fileId: "f1", file: pngA } }), + given(uploadingA()), message(composer(Composer.Message.SubmittedDraft({ markdown: "caption" }))), Command.expectNone(), message(Message.ClickedAttach()), Command.expectNone(), + message(Message.ReceivedDropEvent({ event: DropEvent.DroppedFiles({ files: [pngB] }) })), + Command.expectNone(), + model((current) => expect(current.pendingFiles).toEqual([])), ) }) @@ -169,11 +358,33 @@ describe("attachments", () => { story( update, given(fresh()), - message(Message.ReceivedDropEvent({ event: DropEvent.ChangedDragState({ isDraggingOnPage: true, isDropTarget: true }) })), - model((current) => expect(current.isDropTarget).toBe(true)), + message( + Message.ReceivedDropEvent({ + event: DropEvent.ChangedDragState({ drag: DragState.OverDropZone() }), + }), + ), + model((current) => expect(current.drag).toEqual(DragState.OverDropZone())), message(Message.ReceivedDropEvent({ event: DropEvent.DroppedFiles({ files: [pngA] }) })), Command.expectExact(Draft.GenerateUploadFileId()), - Command.resolve(Draft.GenerateUploadFileId, Message.CompletedGenerateUploadFileId({ fileId: "f1" })), + Command.resolve( + Draft.GenerateUploadFileId, + Message.CompletedGenerateUploadFileId({ fileId: "f1" }), + ), + ) + }) + + test("files of a type a message cannot carry are left out", () => { + const binary = new File(["x"], "tool.exe", { type: "application/x-msdownload" }) + story( + update, + given(fresh()), + message(Message.SelectedFiles({ files: [binary] })), + Command.expectNone(), + expectOutMessage( + Draft.OutMessage.RequestedToast({ + toast: { intent: "error", title: "Unsupported file type", description: null }, + }), + ), ) }) @@ -209,13 +420,13 @@ describe("typing indicator", () => { test("leaving the window deletes the live indicator", () => { story( update, - given({ ...fresh(), typing: { ...Typing.init(), isTyping: true, sessionId: 1, indicatorId, lastContent: "H" } }), + given({ + ...fresh(), + typing: { ...Typing.init(), isTyping: true, sessionId: 1, indicatorId, lastContent: "H" }, + }), message(Message.LeftWindow()), Command.expectExact(Typing.DeleteTypingIndicator({ id: indicatorId })), - Command.resolve( - Typing.DeleteTypingIndicator, - Typing.Message.CompletedDeleteTypingIndicator(), - ), + Command.resolve(Typing.DeleteTypingIndicator, Typing.Message.CompletedDeleteTypingIndicator()), model((current) => expect(current.typing.isTyping).toBe(false)), ) }) @@ -230,16 +441,14 @@ describe("typing indicator", () => { }), ), Command.expectExact(Typing.DeleteTypingIndicator({ id: indicatorId })), - Command.resolve( - Typing.DeleteTypingIndicator, - Typing.Message.CompletedDeleteTypingIndicator(), - ), + Command.resolve(Typing.DeleteTypingIndicator, Typing.Message.CompletedDeleteTypingIndicator()), ) }) test("without a channel membership typing sends nothing", () => { story( - (current: Draft.Model, next: Draft.Message) => DraftUpdate.update(current, next, { ...context, memberId: null }), + (current: Draft.Model, next: Draft.Message) => + DraftUpdate.update(current, next, { ...context, memberId: null }), given(fresh()), message(composer(Composer.Message.UpdatedDraft({ markdown: "H", isEmpty: false }))), Command.expectNone(), @@ -248,17 +457,29 @@ describe("typing indicator", () => { }) describe("slash command execution", () => { + const botId = Schema.decodeSync(BotId)(uuid(6)) const command: Composer.BotCommand = { - id: "command-deploy", + id: Schema.decodeSync(BotCommandId)(uuid(3)), name: "deploy", description: "Deploy", - bot: { id: "bot-ci", name: "CI", avatarUrl: null }, + bot: { id: botId, name: "CI", avatarUrl: null }, arguments: [{ name: "branch", description: null, required: true, placeholder: null, type: "string" }], } - const inCommand = (values: Record): Draft.Model => { + const inCommand = ( + values: Record, + submission: Composer.CommandSubmission = "Idle", + ): Draft.Model => { const base = fresh() - return { ...base, composer: { ...base.composer, commandInput: { command, values, focusedFieldIndex: 0 } } } + return { ...base, composer: { ...base.composer, commandInput: { command, values, submission } } } } + const execute = Draft.ExecuteBotCommand({ + orgId: organizationId, + botId, + commandName: "deploy", + botName: "CI", + channelId, + arguments: [{ name: "branch", value: "main" }], + }) test("a missing required argument toasts and runs nothing", () => { story( @@ -267,48 +488,79 @@ describe("slash command execution", () => { message(composer(Composer.Message.ClickedExecuteCommand())), Command.expectNone(), expectOutMessage( - Draft.OutMessage.RequestedToast({ toast: { intent: "error", title: "Missing required: branch", description: null } }), + Draft.OutMessage.RequestedToast({ + toast: { intent: "error", title: "Missing required: branch", description: null }, + }), ), ) }) test("Enter in a field executes; success leaves command mode and focuses the editor", () => { - const toast: ToastRequest = { intent: "success", title: "Executed /deploy", description: null } story( update, given(inCommand({ branch: "main" })), - message(composer(Composer.Message.PressedCommandFieldKey({ index: 0, key: "Enter", shiftKey: false }))), - Command.expectExact( - Draft.ExecuteBotCommand({ - orgId: organizationId, - botId: "bot-ci", - commandName: "deploy", - botName: "CI", - channelId, - arguments: [{ name: "branch", value: "main" }], + message( + composer( + Composer.Message.PressedCommandFieldKey({ index: 0, key: "Enter", shiftKey: false }), + ), + ), + Command.expectExact(execute), + model((current) => expect(current.composer.commandInput?.submission).toBe("Executing")), + Command.resolve( + Draft.ExecuteBotCommand, + Message.SucceededExecuteBotCommand({ commandName: "deploy" }), + ), + expectOutMessage( + Draft.OutMessage.RequestedToast({ + toast: { intent: "success", title: "Executed /deploy", description: null }, }), ), - Command.resolve(Draft.ExecuteBotCommand, Message.CompletedExecuteBotCommand({ succeeded: true, toast })), - expectOutMessage(Draft.OutMessage.RequestedToast({ toast })), Command.expectExact(EditorCommands.FocusEditor({ editorId })), Command.resolve(EditorCommands.FocusEditor, Composer.Message.CompletedFocusEditor()), model((current) => expect(current.composer.commandInput).toBeNull()), ) }) - test("a failed command keeps the argument panel open", () => { - const toast: ToastRequest = { intent: "error", title: "Command failed", description: null } + test("pressing Execute again while the command runs does not run it twice", () => { story( update, - given(inCommand({ branch: "main" })), - message(Message.CompletedExecuteBotCommand({ succeeded: false, toast })), - expectOutMessage(Draft.OutMessage.RequestedToast({ toast })), - model((current) => expect(current.composer.commandInput?.command.name).toBe("deploy")), + given(inCommand({ branch: "main" }, "Executing")), + message(composer(Composer.Message.ClickedExecuteCommand())), + Command.expectNone(), + message( + composer( + Composer.Message.PressedCommandFieldKey({ index: 0, key: "Enter", shiftKey: false }), + ), + ), + Command.expectNone(), + ) + }) + + test("a failed command keeps the argument panel open, ready again, and names the failure", () => { + story( + update, + given(inCommand({ branch: "main" }, "Executing")), + message( + Message.FailedExecuteBotCommand({ + commandName: "deploy", + botName: "CI", + reason: "BotNotInstalled", + }), + ), + expectOutMessage( + Draft.OutMessage.RequestedToast({ + toast: { intent: "error", title: 'Bot "CI" is not installed', description: null }, + }), + ), + model((current) => { + expect(current.composer.commandInput?.command.name).toBe("deploy") + expect(current.composer.commandInput?.submission).toBe("Idle") + }), ) }) }) -describe("emoji", () => { +describe("emoji and GIFs", () => { test("a unicode emoji is typed into the editor; a custom one is inserted inline", () => { story( update, @@ -316,11 +568,50 @@ describe("emoji", () => { message(Message.SelectedEmoji({ emoji: "🎉", label: "party", imageUrl: null })), Command.expectExact(EditorCommands.InsertEditorText({ editorId, text: "🎉" })), Command.resolve(EditorCommands.InsertEditorText, Composer.Message.CompletedInsertEditorText()), - message(Message.SelectedEmoji({ emoji: ":hazel:", label: "hazel", imageUrl: "https://cdn/hazel.png" })), + message( + Message.SelectedEmoji({ + emoji: ":hazel:", + label: "hazel", + imageUrl: "https://cdn/hazel.png", + }), + ), Command.expectExact( - EditorCommands.InsertCustomEmoji({ editorId, name: "hazel", imageUrl: "https://cdn/hazel.png" }), + EditorCommands.InsertCustomEmoji({ + editorId, + name: "hazel", + imageUrl: "https://cdn/hazel.png", + }), ), Command.resolve(EditorCommands.InsertCustomEmoji, Composer.Message.CompletedInsertCustomEmoji()), ) }) + + test("a GIF answers the reply but leaves an edit in progress alone", () => { + const send = (replyToMessageId: MessageId | null) => + Draft.SendMessage({ + channelId, + authorId: ada, + content: "https://media.klipy.com/cat.gif", + replyToMessageId, + threadChannelId: null, + attachmentIds: [], + restoreContent: false, + }) + story( + update, + given(replying()), + message(Message.SelectedGif({ url: "https://media.klipy.com/cat.gif" })), + Command.expectExact(send(replyId)), + Command.resolve(Draft.SendMessage, Message.SucceededSendMessage()), + model((current) => expect(current.composeMode).toEqual(Draft.ComposeMode.Plain())), + ) + story( + update, + given(editing()), + message(Message.SelectedGif({ url: "https://media.klipy.com/cat.gif" })), + Command.expectExact(send(null)), + Command.resolve(Draft.SendMessage, Message.SucceededSendMessage()), + model((current) => expect(Draft.editingMessageIdOf(current)).toBe(editId)), + ) + }) }) diff --git a/apps/web-foldkit/src/composer/draft.ts b/apps/web-foldkit/src/composer/draft.ts index 5b380d3e0..59bc50d84 100644 --- a/apps/web-foldkit/src/composer/draft.ts +++ b/apps/web-foldkit/src/composer/draft.ts @@ -7,21 +7,21 @@ import { OrganizationId, UserId, } from "@hazel/schema" -import { Cause, Effect, Schema } from "effect" +import { Effect, Schema } from "effect" import { Command, File } from "foldkit" +import * as Dom from "foldkit/dom" import { defineMessageUnion } from "foldkit/message" +import { defineTaggedUnion } from "foldkit/schema" import { editMessageAction, sendMessageAction } from "~/db/actions" -import { HazelApiClient } from "~/lib/services/common/atom-client" +import { CommonReason } from "~/lib/error-messages" +import { HazelApiClient } from "../rpc" import { ToastRequest } from "../overlay/toasts" -import { errorToast, failureToast, runAtomFn, successToast, toastOfExit } from "../data/actions" -import { notRetryable, rateLimited } from "../page/chat/action-effects" +import { catchCommon } from "../data/actions" import * as Composer from "./composer" import * as Typing from "./typing" -import { emojiUsageAtom } from "~/atoms/emoji-atoms" import * as EmojiDialog from "../emoji-picker/dialog" import * as GifPicker from "../gif-picker/picker" -import { trackEmojiUsage } from "../emoji-picker/usage" -import { DropEvent } from "./drop" +import { DragState, DropEvent } from "./drop" import { UploadEvent } from "./upload" /** @@ -32,29 +32,31 @@ import { UploadEvent } from "./upload" // MODEL -export const UploadingFile = Schema.Struct({ - fileId: Schema.String, - fileName: Schema.String, - fileSize: Schema.Number, - progress: Schema.Number, +/** What Send does with the draft: a new message, a reply, or an edit (never both). */ +export const ComposeMode = defineTaggedUnion({ + Plain: {}, + Replying: { messageId: MessageId }, + Editing: { messageId: MessageId }, }) -export type UploadingFile = typeof UploadingFile.Type +export type ComposeMode = typeof ComposeMode.Type +/** The file being uploaded: what the upload Subscription depends on. */ export const CurrentUpload = Schema.Struct({ fileId: Schema.String, file: File.File }) +export type CurrentUpload = typeof CurrentUpload.Type + +export const ActiveUpload = Schema.Struct({ fileId: Schema.String, file: File.File, progress: Schema.Number }) +export type ActiveUpload = typeof ActiveUpload.Type export const Model = Schema.Struct({ channelId: ChannelId, composer: Composer.Model, - replyToMessageId: Schema.NullOr(MessageId), - editingMessageId: Schema.NullOr(MessageId), + composeMode: ComposeMode, attachmentIds: Schema.Array(AttachmentId), - uploadingFiles: Schema.Array(UploadingFile), /** Files waiting their turn; legacy uploads them one after another. */ pendingFiles: Schema.Array(File.File), - currentUpload: Schema.NullOr(CurrentUpload), + currentUpload: Schema.NullOr(ActiveUpload), typing: Typing.Model, - isDraggingOnPage: Schema.Boolean, - isDropTarget: Schema.Boolean, + drag: DragState, /** The Emoji button's `EmojiPickerDialog`. */ emojiPicker: EmojiDialog.Model, /** The GIF button's `GifPickerDialog`. */ @@ -65,33 +67,52 @@ export type Model = typeof Model.Type export const init = (channelId: ChannelId, editorId: string, placeholder = "Type a message..."): Model => ({ channelId, composer: Composer.init(editorId, placeholder), - replyToMessageId: null, - editingMessageId: null, + composeMode: ComposeMode.Plain(), attachmentIds: [], - uploadingFiles: [], pendingFiles: [], currentUpload: null, typing: Typing.init(), - isDraggingOnPage: false, - isDropTarget: false, + drag: DragState.Idle(), emojiPicker: EmojiDialog.init(`${editorId}-emoji-picker`), gifPicker: GifPicker.init(`${editorId}-gif-picker`), }) +export const replyToMessageIdOf = (model: Model): MessageId | null => + model.composeMode._tag === "Replying" ? model.composeMode.messageId : null + +export const editingMessageIdOf = (model: Model): MessageId | null => + model.composeMode._tag === "Editing" ? model.composeMode.messageId : null + /** `useChatDraft().isUploading`. */ export const isUploading = (model: Model) => model.currentUpload !== null || model.pendingFiles.length > 0 +/** The upload Subscription's dependency: the file and its id, not its progress. */ +export const currentUploadOf = (model: Model): CurrentUpload | null => + model.currentUpload === null + ? null + : { fileId: model.currentUpload.fileId, file: model.currentUpload.file } + /** `ComposerFrame`'s `hasTopContent`: indicators or previews sit on the frame's top edge. */ export const hasTopContent = (model: Model) => - model.replyToMessageId !== null || - model.editingMessageId !== null || - model.attachmentIds.length > 0 || - model.uploadingFiles.length > 0 + model.composeMode._tag !== "Plain" || model.attachmentIds.length > 0 || model.currentUpload !== null export const fileInputId = (model: Model) => `${model.composer.editorId}-file-input` // MESSAGE +/** Why a send failed, beyond the shared reasons. `SyncDelayed`: it was delivered, the sync lags. */ +export const SendFailure = Schema.Union([Schema.Literals(["ChannelNotFound", "SyncDelayed"]), CommonReason]) +export type SendFailure = typeof SendFailure.Type + +export const EditFailure = Schema.Union([Schema.Literal("MessageNotFound"), CommonReason]) +export type EditFailure = typeof EditFailure.Type + +export const BotCommandFailure = Schema.Union([ + Schema.Literals(["BotNotFound", "BotNotInstalled", "CommandNotFound", "ExecutionFailed"]), + CommonReason, +]) +export type BotCommandFailure = typeof BotCommandFailure.Type + export const Message = defineMessageUnion({ GotComposerMessage: { message: Composer.Message }, GotTypingMessage: { message: Typing.Message }, @@ -110,16 +131,21 @@ export const Message = defineMessageUnion({ restoreContent: Schema.Boolean, replyToMessageId: Schema.NullOr(MessageId), attachmentIds: Schema.Array(AttachmentId), - toast: ToastRequest, + reason: SendFailure, }, - CompletedEditMessage: { toast: Schema.NullOr(ToastRequest) }, + SucceededEditMessage: {}, + FailedEditMessage: { reason: EditFailure }, LeftWindow: {}, SelectedEmoji: { emoji: Schema.String, label: Schema.String, imageUrl: Schema.NullOr(Schema.String) }, SelectedGif: { url: Schema.String }, GotEmojiPickerMessage: { message: EmojiDialog.Message }, GotGifPickerMessage: { message: GifPicker.Message }, - CompletedTrackEmojiUsage: {}, - CompletedExecuteBotCommand: { succeeded: Schema.Boolean, toast: ToastRequest }, + SucceededExecuteBotCommand: { commandName: Schema.String }, + FailedExecuteBotCommand: { + commandName: Schema.String, + botName: Schema.String, + reason: BotCommandFailure, + }, }) export type Message = typeof Message.Type @@ -156,37 +182,39 @@ export const SendMessage = Command.define("SendMessage", { restoreContent: Schema.Boolean, }, messages: [Message.SucceededSendMessage, Message.FailedSendMessage], - execute: ({ restoreContent, ...variables }) => - Effect.matchCause(runAtomFn(sendMessageAction, { ...variables, attachmentIds: [...variables.attachmentIds] }), { - onSuccess: () => Message.SucceededSendMessage(), - onFailure: (cause) => - Message.FailedSendMessage({ - content: variables.content, - restoreContent, - replyToMessageId: variables.replyToMessageId, - attachmentIds: variables.attachmentIds, - toast: failureToast(cause, "exitToast", sendHandlers), - }), - }), + execute: ({ restoreContent, ...variables }) => { + const failed = (reason: SendFailure) => + Message.FailedSendMessage({ + content: variables.content, + restoreContent, + replyToMessageId: variables.replyToMessageId, + attachmentIds: variables.attachmentIds, + reason, + }) + return sendMessageAction({ ...variables, attachmentIds: [...variables.attachmentIds] }).pipe( + Effect.as(Message.SucceededSendMessage()), + Effect.catchTags({ + ChannelNotFoundError: () => Effect.succeed(failed("ChannelNotFound")), + SyncError: () => Effect.succeed(failed("SyncDelayed")), + }), + catchCommon(failed), + ) + }, }) -const sendHandlers = { - RateLimitExceededError: rateLimited("sending another message"), - ChannelNotFoundError: notRetryable("Channel not found", "This channel may have been deleted."), - SyncError: notRetryable("Message sent", "Sync is delayed but your message was delivered."), -} - /** `ChatProvider.editMessage`. */ export const EditMessage = Command.define("EditMessage", { args: { messageId: MessageId, content: Schema.String }, - messages: [Message.CompletedEditMessage], + messages: [Message.SucceededEditMessage, Message.FailedEditMessage], execute: (args) => - toastOfExit(runAtomFn(editMessageAction, args), "exitToast", { - handlers: { - RateLimitExceededError: rateLimited("trying again"), - MessageNotFoundError: notRetryable("Message not found", "This message may have been deleted."), - }, - }).pipe(Effect.map((toast) => Message.CompletedEditMessage({ toast }))), + editMessageAction(args).pipe( + Effect.as(Message.SucceededEditMessage()), + Effect.catchTags({ + MessageNotFoundError: () => + Effect.succeed(Message.FailedEditMessage({ reason: "MessageNotFound" })), + }), + catchCommon((reason) => Message.FailedEditMessage({ reason })), + ), }) /** `handleFilesUpload` names each upload with a fresh id. */ @@ -200,63 +228,41 @@ export const OpenFilePicker = Command.define("OpenFilePicker", { args: { inputId: Schema.String }, messages: [Message.CompletedOpenFilePicker], execute: ({ inputId }) => - Effect.sync(() => document.getElementById(inputId)?.click()).pipe(Effect.as(Message.CompletedOpenFilePicker())), + Dom.clickElement(`#${CSS.escape(inputId)}`).pipe( + // Best-effort: the input renders with the composer, so it is only missing mid-unmount. + Effect.ignore, + Effect.as(Message.CompletedOpenFilePicker()), + ), }) -const executeBotCommand = HazelApiClient.mutation("bot-commands", "executeBotCommand") - -const tagOf = (error: unknown) => - typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : undefined - /** The slash command panel's Execute (`bot-commands.executeBotCommand`). */ export const ExecuteBotCommand = Command.define("ExecuteBotCommand", { args: { orgId: OrganizationId, - botId: Schema.String, + botId: BotId, commandName: Schema.String, botName: Schema.String, channelId: ChannelId, arguments: Schema.Array(Schema.Struct({ name: Schema.String, value: Schema.String })), }, - messages: [Message.CompletedExecuteBotCommand], - execute: ({ orgId, botId, commandName, botName, channelId, arguments: args }) => - Effect.matchCause( - runAtomFn(executeBotCommand, { - params: { orgId, botId: BotId.make(botId), commandName }, + messages: [Message.SucceededExecuteBotCommand, Message.FailedExecuteBotCommand], + execute: ({ orgId, botId, commandName, botName, channelId, arguments: args }) => { + const failed = (reason: BotCommandFailure) => + Message.FailedExecuteBotCommand({ commandName, botName, reason }) + return HazelApiClient.use((client) => + client["bot-commands"].executeBotCommand({ + params: { orgId, botId, commandName }, payload: { channelId, arguments: [...args] }, }), - { - onSuccess: () => - Message.CompletedExecuteBotCommand({ - succeeded: true, - toast: successToast(`Executed /${commandName}`), - }), - onFailure: (cause) => { - const error = cause.reasons.find(Cause.isFailReason)?.error - const tag = tagOf(error) - const title = - error === undefined - ? "Command failed" - : tag === "BotNotInstalledError" - ? `Bot "${botName}" is not installed` - : tag === "BotCommandNotFoundError" - ? `Command /${commandName} not found` - : tag === "BotCommandExecutionError" && typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "Command execution failed" - return Message.CompletedExecuteBotCommand({ - succeeded: false, - toast: errorToast(title), - }) - }, - }, - ), -}) - -/** `ComposerActions.handleEmojiSelect` counts the emoji before inserting it. */ -export const TrackEmojiUsage = Command.define("TrackEmojiUsage", { - args: { emoji: Schema.String }, - messages: [Message.CompletedTrackEmojiUsage], - execute: ({ emoji }) => - Effect.sync(() => trackEmojiUsage(emojiUsageAtom, emoji)).pipe(Effect.as(Message.CompletedTrackEmojiUsage())), + ).pipe( + Effect.as(Message.SucceededExecuteBotCommand({ commandName })), + Effect.catchTags({ + BotNotFoundError: () => Effect.succeed(failed("BotNotFound")), + BotNotInstalledError: () => Effect.succeed(failed("BotNotInstalled")), + BotCommandNotFoundError: () => Effect.succeed(failed("CommandNotFound")), + BotCommandExecutionError: () => Effect.succeed(failed("ExecutionFailed")), + }), + catchCommon(failed), + ) + }, }) diff --git a/apps/web-foldkit/src/composer/drop.ts b/apps/web-foldkit/src/composer/drop.ts index 32cf68e70..bec7713cc 100644 --- a/apps/web-foldkit/src/composer/drop.ts +++ b/apps/web-foldkit/src/composer/drop.ts @@ -1,37 +1,25 @@ import { Effect, Queue, Schema, Stream } from "effect" import { File, Mount } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import { defineTaggedUnion } from "foldkit/schema" +import { isAcceptedMimeType } from "../editor/file-types" /** * `ComposerDropZone`: React Aria's `DropZone` (drop target, accepted types) plus `useDragDetection` - * (files dragged anywhere on the page), as one Mount on the drop zone element. + * (files dragged anywhere on the page), as one Mount on the drop zone element. Whether a drop is + * taken (not while a file uploads) is the update's call: the Mount reports every drop. */ -/** `getDropOperation`: the concrete types React Aria checks for the wildcard entries. */ -const ACCEPTED_TYPES = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/svg+xml", - "video/mp4", - "video/webm", - "video/quicktime", - "audio/mpeg", - "audio/wav", - "audio/ogg", - "audio/webm", - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "text/plain", - "text/csv", -] +/** Files dragged over the page (a subtle overlay) or over the drop zone itself (a strong one). */ +export const DragState = defineTaggedUnion({ + Idle: {}, + DraggingOnPage: {}, + OverDropZone: {}, +}) +export type DragState = typeof DragState.Type export const DropEvent = defineMessageUnion({ - ChangedDragState: { isDraggingOnPage: Schema.Boolean, isDropTarget: Schema.Boolean }, + ChangedDragState: { drag: DragState }, DroppedFiles: { files: Schema.Array(File.File) }, }) export type DropEvent = typeof DropEvent.Type @@ -39,12 +27,20 @@ export type DropEvent = typeof DropEvent.Type const hasFiles = (event: DragEvent) => event.dataTransfer?.types.includes("Files") ?? false const isAccepted = (event: DragEvent) => - Array.from(event.dataTransfer?.items ?? []).some((item) => item.kind === "file" && ACCEPTED_TYPES.includes(item.type)) + Array.from(event.dataTransfer?.items ?? []).some( + (item) => item.kind === "file" && isAcceptedMimeType(item.type), + ) + +const dragStateOf = (pageCounter: number, targetCounter: number): DragState => + targetCounter > 0 + ? DragState.OverDropZone() + : pageCounter > 0 + ? DragState.DraggingOnPage() + : DragState.Idle() export const TrackFileDrop = Mount.defineStream("TrackFileDrop", { - args: { isDisabled: Schema.Boolean }, messages: [DropEvent.ChangedDragState, DropEvent.DroppedFiles], - execute: ({ element, isDisabled }) => + execute: ({ element }) => Stream.callback((queue) => Effect.acquireRelease( Effect.sync(() => { @@ -53,7 +49,7 @@ export const TrackFileDrop = Mount.defineStream("TrackFileDrop", { const emit = () => Queue.offerUnsafe( queue, - DropEvent.ChangedDragState({ isDraggingOnPage: pageCounter > 0, isDropTarget: targetCounter > 0 }), + DropEvent.ChangedDragState({ drag: dragStateOf(pageCounter, targetCounter) }), ) const reset = () => { pageCounter = 0 @@ -70,7 +66,7 @@ export const TrackFileDrop = Mount.defineStream("TrackFileDrop", { emit() } const onEnter = (event: DragEvent) => { - if (isDisabled || !isAccepted(event)) return + if (!isAccepted(event)) return targetCounter++ emit() } @@ -78,11 +74,11 @@ export const TrackFileDrop = Mount.defineStream("TrackFileDrop", { targetCounter = Math.max(0, targetCounter - 1) emit() } + // `preventDefault` has to happen in the listener for the element to accept the drop. const onOver = (event: DragEvent) => { - if (!isDisabled && isAccepted(event)) event.preventDefault() + if (isAccepted(event)) event.preventDefault() } const onDrop = (event: DragEvent) => { - if (isDisabled) return event.preventDefault() const files = Array.from(event.dataTransfer?.files ?? []) reset() diff --git a/apps/web-foldkit/src/composer/editor-commands.ts b/apps/web-foldkit/src/composer/editor-commands.ts index d754636ef..7a8b8c83c 100644 --- a/apps/web-foldkit/src/composer/editor-commands.ts +++ b/apps/web-foldkit/src/composer/editor-commands.ts @@ -1,5 +1,7 @@ import { Effect, Schema } from "effect" import { Command } from "foldkit" +import * as Dom from "foldkit/dom" +import * as Render from "foldkit/render" import { clearContent, focusAndInsertText, @@ -9,85 +11,119 @@ import { removeTriggerText, setContent, } from "../editor/operations" -import { ComposerEmoji, Message, withEditor } from "./composer" +import { withEditor } from "../editor/registry" +import { ComposerEmoji, Message, onEditor } from "./composer" -/** Commands that act on a live composer editor (the legacy `SlateMessageEditorRef` calls). */ - -/** Legacy inserts in `requestAnimationFrame`, after a closing picker has left the DOM. */ -const nextFrame = Effect.callback((resume) => { - requestAnimationFrame(() => resume(Effect.void)) -}) +/** + * Commands that act on a live composer editor (the legacy `SlateMessageEditorRef` calls). The update + * that returns one already wrote the result into the Model where it matters (`markdown`), so a + * `Failed*` for an unmounted editor loses nothing: the next mount starts from the Model. + */ /** `setContent` plus `focus` (editing a message, restoring a failed send). */ export const SetEditorContent = Command.define("SetEditorContent", { args: { editorId: Schema.String, markdown: Schema.String }, - messages: [Message.CompletedSetEditorContent], + messages: [Message.CompletedSetEditorContent, Message.FailedSetEditorContent], execute: ({ editorId, markdown }) => - withEditor(editorId, (view) => { - setContent(view, markdown) - focusAtEnd(view) - }).pipe(Effect.as(Message.CompletedSetEditorContent())), + onEditor( + editorId, + (view) => { + setContent(view, markdown) + focusAtEnd(view) + }, + Message.CompletedSetEditorContent(), + Message.FailedSetEditorContent, + ), }) /** `clearContent` (legacy `resetAndFocus`). */ export const ClearEditor = Command.define("ClearEditor", { args: { editorId: Schema.String }, - messages: [Message.CompletedClearEditor], - execute: ({ editorId }) => withEditor(editorId, clearContent).pipe(Effect.as(Message.CompletedClearEditor())), + messages: [Message.CompletedClearEditor, Message.FailedClearEditor], + execute: ({ editorId }) => + onEditor(editorId, clearContent, Message.CompletedClearEditor(), Message.FailedClearEditor), }) export const FocusEditor = Command.define("FocusEditor", { args: { editorId: Schema.String }, messages: [Message.CompletedFocusEditor], - execute: ({ editorId }) => withEditor(editorId, focusAtEnd).pipe(Effect.as(Message.CompletedFocusEditor())), + execute: ({ editorId }) => + withEditor(editorId, focusAtEnd).pipe( + // Best-effort: an unmounted editor has nothing to focus. + Effect.ignore, + Effect.as(Message.CompletedFocusEditor()), + ), }) /** `focusAndInsertText`: the emoji picker and the global typing redirect. */ export const InsertEditorText = Command.define("InsertEditorText", { args: { editorId: Schema.String, text: Schema.String }, - messages: [Message.CompletedInsertEditorText], + messages: [Message.CompletedInsertEditorText, Message.FailedInsertEditorText], execute: ({ editorId, text }) => - nextFrame.pipe( - Effect.andThen(withEditor(editorId, (view) => focusAndInsertText(view, text))), - Effect.as(Message.CompletedInsertEditorText()), + // Legacy inserts once a closing picker has left the DOM. + Render.afterCommit.pipe( + Effect.andThen( + onEditor( + editorId, + (view) => focusAndInsertText(view, text), + Message.CompletedInsertEditorText(), + Message.FailedInsertEditorText, + ), + ), ), }) export const InsertCustomEmoji = Command.define("InsertCustomEmoji", { args: { editorId: Schema.String, name: Schema.String, imageUrl: Schema.String }, - messages: [Message.CompletedInsertCustomEmoji], + messages: [Message.CompletedInsertCustomEmoji, Message.FailedInsertCustomEmoji], execute: ({ editorId, name, imageUrl }) => - nextFrame.pipe( - Effect.andThen(withEditor(editorId, (view) => insertCustomEmoji(view, name, imageUrl))), - Effect.as(Message.CompletedInsertCustomEmoji()), + Render.afterCommit.pipe( + Effect.andThen( + onEditor( + editorId, + (view) => insertCustomEmoji(view, name, imageUrl), + Message.CompletedInsertCustomEmoji(), + Message.FailedInsertCustomEmoji, + ), + ), ), }) export const InsertEmoji = Command.define("InsertEmoji", { args: { editorId: Schema.String, emoji: Schema.String, custom: Schema.NullOr(ComposerEmoji) }, - messages: [Message.CompletedInsertEmoji], + messages: [Message.CompletedInsertEmoji, Message.FailedInsertEmoji], execute: ({ editorId, emoji, custom }) => - withEditor(editorId, (view) => insertEmojiResult(view, emoji, custom)).pipe( - Effect.as(Message.CompletedInsertEmoji()), + onEditor( + editorId, + (view) => insertEmojiResult(view, emoji, custom), + Message.CompletedInsertEmoji(), + Message.FailedInsertEmoji, ), }) /** Entering command input mode deletes the `/query` text. */ export const RemoveTriggerText = Command.define("RemoveTriggerText", { args: { editorId: Schema.String }, - messages: [Message.CompletedRemoveTriggerText], + messages: [Message.CompletedRemoveTriggerText, Message.FailedRemoveTriggerText], execute: ({ editorId }) => - withEditor(editorId, removeTriggerText).pipe(Effect.as(Message.CompletedRemoveTriggerText())), + onEditor( + editorId, + removeTriggerText, + Message.CompletedRemoveTriggerText(), + Message.FailedRemoveTriggerText, + ), }) export const commandFieldId = (editorId: string, index: number) => `${editorId}-command-field-${index}` -/** `CommandInputPanel` focuses the field at `focusedFieldIndex`. */ +/** `CommandInputPanel` focuses a field when the panel opens and on Tab. */ export const FocusCommandField = Command.define("FocusCommandField", { args: { editorId: Schema.String, index: Schema.Number }, messages: [Message.CompletedFocusCommandField], execute: ({ editorId, index }) => - Effect.sync(() => document.getElementById(commandFieldId(editorId, index))?.focus()).pipe( + Dom.focus(`#${CSS.escape(commandFieldId(editorId, index))}`).pipe( + // Best-effort: a command without arguments has no field to focus. + Effect.ignore, Effect.as(Message.CompletedFocusCommandField()), ), }) diff --git a/apps/web-foldkit/src/composer/trigger-views.ts b/apps/web-foldkit/src/composer/trigger-views.ts index ba70e27d3..3047ea211 100644 --- a/apps/web-foldkit/src/composer/trigger-views.ts +++ b/apps/web-foldkit/src/composer/trigger-views.ts @@ -4,7 +4,13 @@ import { cx } from "~/utils/cx" import { type BotCommand, type CommandInput, Message, type Model } from "./composer" import { commandFieldId } from "./editor-commands" import { listBox } from "./autocomplete-view" -import { clampedActiveIndex, type CommandOption, commandOptions, type EmojiOption, emojiOptions } from "./options" +import { + clampedActiveIndex, + type CommandOption, + commandOptions, + type EmojiOption, + emojiOptions, +} from "./options" /** `CommandTrigger` / `BotCommandItem`, `EmojiTrigger` / `EmojiItem` and `CommandInputPanel`. */ @@ -30,14 +36,19 @@ const botCommandItem = (h: HtmlBuilder, option: CommandOption, isFocused: h.div( [h.Class("flex flex-wrap items-center gap-1.5")], [ - h.span([h.Class(cx("font-medium", isFocused ? "text-primary" : "text-fg"))], [`/${command.name}`]), + h.span( + [h.Class(cx("font-medium", isFocused ? "text-primary" : "text-fg"))], + [`/${command.name}`], + ), ...command.arguments.map((arg) => h.span( [ h.Class( cx( "rounded px-1.5 py-0.5 text-xs", - arg.required ? "bg-warning/20 text-warning" : "bg-muted text-muted-fg", + arg.required + ? "bg-warning/20 text-warning" + : "bg-muted text-muted-fg", ), ), ], @@ -53,10 +64,15 @@ const botCommandItem = (h: HtmlBuilder, option: CommandOption, isFocused: ) } -export const commandTriggerContent = (h: HtmlBuilder, model: Model, toMessage: (message: Message) => M): Html => { +export const commandTriggerContent = ( + h: HtmlBuilder, + model: Model, + toMessage: (message: Message) => M, +): Html => { const activeIndex = clampedActiveIndex(model) return listBox( h, + model.editorId, commandOptions(model).map((option, index) => ({ id: option.id, content: botCommandItem(h, option, index === activeIndex), @@ -72,15 +88,24 @@ const emojiItem = (h: HtmlBuilder, option: EmojiOption): Html => [h.Class("flex items-center gap-2")], [ option.imageUrl - ? h.img([h.Attribute("src", option.imageUrl), h.Attribute("alt", option.name), h.Class("size-5 object-contain")]) + ? h.img([ + h.Attribute("src", option.imageUrl), + h.Attribute("alt", option.name), + h.Class("size-5 object-contain"), + ]) : h.span([h.Class("text-xl")], [option.emoji]), h.span([h.Class("text-muted-fg")], [`:${option.name}:`]), ], ) -export const emojiTriggerContent = (h: HtmlBuilder, model: Model, toMessage: (message: Message) => M): Html => +export const emojiTriggerContent = ( + h: HtmlBuilder, + model: Model, + toMessage: (message: Message) => M, +): Html => listBox( h, + model.editorId, emojiOptions(model).map((option) => ({ id: option.id, content: emojiItem(h, option) })), clampedActiveIndex(model), (model.autocomplete?.search.length ?? 0) < 2 ? "Type at least 2 characters" : "No emoji found", @@ -97,6 +122,7 @@ export const commandInputPanel = ( toMessage: (message: Message) => M, ): Html => { const { command } = input + const isExecuting = input.submission === "Executing" return h.div( [h.Class("mb-2 rounded-lg border border-border bg-surface-2 p-4")], [ @@ -123,28 +149,42 @@ export const commandInputPanel = ( [ h.span( [h.Class("mb-1 block text-fg text-sm")], - [arg.name, arg.required ? h.span([h.Class("ml-1 text-warning")], ["*"]) : h.empty], + [ + arg.name, + arg.required + ? h.span( + [h.Class("ml-1 text-warning"), h.AriaHidden(true)], + ["*"], + ) + : h.empty, + ], ), h.input([ h.Id(commandFieldId(editorId, index)), h.Attribute("type", arg.type === "number" ? "number" : "text"), h.Value(input.values[arg.name] ?? ""), h.Attribute("placeholder", arg.placeholder ?? arg.description ?? ""), - h.OnInput((value) => toMessage(Message.UpdatedCommandValue({ argName: arg.name, value }))), - h.OnFocus(toMessage(Message.FocusedCommandField({ index }))), + h.AriaRequired(arg.required), + h.OnInput((value) => + toMessage( + Message.UpdatedCommandValue({ argName: arg.name, value }), + ), + ), h.OnKeyDownPreventDefault((key, modifiers) => commandKeys.includes(key) ? Option.some( - toMessage(Message.PressedCommandFieldKey({ index, key, shiftKey: modifiers.shiftKey })), + toMessage( + Message.PressedCommandFieldKey({ + index, + key, + shiftKey: modifiers.shiftKey, + }), + ), ) : Option.none(), ), h.Class( - cx( - "w-full rounded-md border bg-surface px-3 py-2 text-sm", - "focus:border-primary focus:outline-none", - input.focusedFieldIndex === index ? "border-primary" : "border-border", - ), + "w-full rounded-md border border-border bg-surface px-3 py-2 text-sm focus:border-primary focus:outline-none", ), ]), ], @@ -159,7 +199,9 @@ export const commandInputPanel = ( [ h.Attribute("type", "button"), h.OnClick(toMessage(Message.ClickedCancelCommand())), - h.Class("rounded-md px-3 py-1.5 text-muted-fg text-sm hover:bg-muted hover:text-fg"), + h.Class( + "rounded-md px-3 py-1.5 text-muted-fg text-sm hover:bg-muted hover:text-fg", + ), ], ["Cancel"], ), @@ -167,9 +209,12 @@ export const commandInputPanel = ( [ h.Attribute("type", "button"), h.OnClick(toMessage(Message.ClickedExecuteCommand())), - h.Class("rounded-md bg-primary px-3 py-1.5 text-primary-fg text-sm hover:bg-primary/90"), + ...(isExecuting ? [h.Disabled(true), h.AriaBusy(true)] : []), + h.Class( + "rounded-md bg-primary px-3 py-1.5 text-primary-fg text-sm hover:bg-primary/90 disabled:opacity-50", + ), ], - ["Execute"], + [isExecuting ? "Executing…" : "Execute"], ), ], ), diff --git a/apps/web-foldkit/src/composer/update.ts b/apps/web-foldkit/src/composer/update.ts index 9d0048e26..5c38911de 100644 --- a/apps/web-foldkit/src/composer/update.ts +++ b/apps/web-foldkit/src/composer/update.ts @@ -1,132 +1,222 @@ import { Match } from "effect" import type { Update } from "foldkit" -import { CloseAutocomplete, InsertMention, Message, type Model, SyncAutocompleteOptions } from "./composer" -import { FocusCommandField, FocusEditor, InsertEmoji, RemoveTriggerText } from "./editor-commands" +import { modifyFields } from "foldkit/struct" +import type { EditorRegistry } from "../editor/registry" +import { + CloseAutocomplete, + InsertMention, + Message, + type CommandInput, + type CommandSubmission, + type Model, + optionDomId, + SyncAutocompleteOptions, +} from "./composer" +import { + ClearEditor, + FocusCommandField, + FocusEditor, + InsertEmoji, + RemoveTriggerText, + SetEditorContent, +} from "./editor-commands" import { clampedActiveIndex, commandOptions, emojiOptions, mentionOptions, optionCount } from "./options" -/** The composer's own update: the autocomplete listbox, trigger selection and command input fields. */ +/** The composer's own update: the draft text, the autocomplete listbox, trigger selection and command input fields. */ export { clampedActiveIndex, mentionOptions, type MentionOption } from "./options" -type Return = Update.Return +export type Return = Update.Return -/** Keep the editor's key capture in step with what the popover shows. */ +const activeOptionIdOf = (model: Model) => + optionCount(model) === 0 ? null : optionDomId(model.editorId, clampedActiveIndex(model)) + +/** Keep the editor's key capture and `aria-activedescendant` in step with what the popover shows. */ const withOptionSync = (previous: Model, model: Model): Return => - optionCount(previous) === optionCount(model) + optionCount(previous) === optionCount(model) && activeOptionIdOf(previous) === activeOptionIdOf(model) ? { model } - : { model, commands: [SyncAutocompleteOptions({ editorId: model.editorId, optionCount: optionCount(model) })] } + : { + model, + commands: [ + SyncAutocompleteOptions({ + editorId: model.editorId, + optionCount: optionCount(model), + activeOptionId: activeOptionIdOf(model), + }), + ], + } + +/** Replace the draft: the Model first, then the live editor (if it is mounted). */ +export const setDraft = (model: Model, markdown: string): Return => ({ + model: modifyFields(model, { markdown: () => markdown }), + commands: [SetEditorContent({ editorId: model.editorId, markdown })], +}) + +/** Empty the draft. */ +export const clearDraft = (model: Model): Return => ({ + model: modifyFields(model, { markdown: () => "" }), + commands: [ClearEditor({ editorId: model.editorId })], +}) /** Legacy `handleSelectByIndex`, routed by trigger. */ const select = (model: Model, index: number): Return => { const editorId = model.editorId - const reset = { ...model, activeIndex: 0 } - const trigger = model.autocomplete?.trigger - if (trigger === "mention") { - const option = mentionOptions(model)[index] - return option - ? { model: reset, commands: [InsertMention({ editorId, userId: option.id, displayName: option.displayName })] } - : { model } - } - if (trigger === "command") { - const option = commandOptions(model)[index] + const reset = modifyFields(model, { activeIndex: () => 0 }) + return Match.value(model.autocomplete?.trigger).pipe( + Match.withReturnType(), + Match.when("mention", () => { + const option = mentionOptions(model)[index] + return option + ? { + model: reset, + commands: [ + InsertMention({ editorId, userId: option.id, displayName: option.displayName }), + ], + } + : { model } + }), // Command input mode (Discord-style): the `/query` text goes, the argument panel opens. - return option - ? { - model: { ...reset, commandInput: { command: option.command, values: {}, focusedFieldIndex: 0 } }, - commands: [RemoveTriggerText({ editorId }), FocusCommandField({ editorId, index: 0 })], - } - : { model } - } - const option = emojiOptions(model)[index] - return option - ? { - model: reset, - commands: [ - InsertEmoji({ - editorId, - emoji: option.emoji, - custom: option.imageUrl === null ? null : { name: option.name, imageUrl: option.imageUrl }, - }), - ], - } - : { model } + Match.when("command", () => { + const option = commandOptions(model)[index] + return option + ? { + model: modifyFields(reset, { + commandInput: () => ({ + command: option.command, + values: {}, + submission: "Idle" as const, + }), + }), + commands: [ + RemoveTriggerText({ editorId }), + FocusCommandField({ editorId, index: 0 }), + ], + } + : { model } + }), + Match.when("emoji", () => { + const option = emojiOptions(model)[index] + return option + ? { + model: reset, + commands: [ + InsertEmoji({ + editorId, + emoji: option.emoji, + custom: + option.imageUrl === null + ? null + : { name: option.name, imageUrl: option.imageUrl }, + }), + ], + } + : { model } + }), + Match.when(undefined, () => ({ model })), + Match.exhaustive, + ) } -const focusField = (model: Model, index: number): Return => - model.commandInput === null - ? { model } - : { - model: { ...model, commandInput: { ...model.commandInput, focusedFieldIndex: index } }, - commands: [FocusCommandField({ editorId: model.editorId, index })], - } - /** `handleCommandCancel`: leave input mode and focus the editor. */ export const cancelCommand = (model: Model): Return => ({ - model: { ...model, commandInput: null }, + model: modifyFields(model, { commandInput: () => null }), commands: [FocusEditor({ editorId: model.editorId })], }) +/** Edit the open command input (nothing without one). */ +const modifyCommandInput = (model: Model, f: (input: CommandInput) => CommandInput): Model => + modifyFields(model, { commandInput: (input) => (input === null ? null : f(input)) }) + +/** Mark the command's Execute as in flight (or settled). */ +export const setCommandSubmission = (model: Model, submission: CommandSubmission): Model => + modifyCommandInput(model, (input) => modifyFields(input, { submission: () => submission })) + +const noChange = (model: Model): Return => ({ model }) + export const update = (model: Model, message: Message): Return => Message.match(message, { - // The editor owns its document; the draft reads the markdown for typing (`draft-update.ts`). - UpdatedDraft: () => ({ model }), + UpdatedDraft: ({ markdown }) => ({ model: modifyFields(model, { markdown: () => markdown }) }), ChangedAutocomplete: ({ autocomplete }) => - withOptionSync(model, { ...model, autocomplete, activeIndex: autocomplete === null ? 0 : model.activeIndex }), + withOptionSync( + model, + modifyFields(model, { + autocomplete: () => autocomplete, + activeIndex: (index) => (autocomplete === null ? 0 : index), + }), + ), PressedAutocompleteKey: ({ key }) => { const count = optionCount(model) const index = clampedActiveIndex(model) + const moveTo = (next: number) => + withOptionSync(model, modifyFields(model, { activeIndex: () => next })) return Match.value(key).pipe( - Match.when("ArrowDown", (): Return => ({ model: { ...model, activeIndex: index >= count - 1 ? 0 : index + 1 } })), - Match.when("ArrowUp", (): Return => ({ model: { ...model, activeIndex: index <= 0 ? count - 1 : index - 1 } })), + Match.when("ArrowDown", () => moveTo(index >= count - 1 ? 0 : index + 1)), + Match.when("ArrowUp", () => moveTo(index <= 0 ? count - 1 : index - 1)), Match.whenOr("Enter", "Tab", () => select(model, index)), - Match.when("Escape", (): Return => ({ model, commands: [CloseAutocomplete({ editorId: model.editorId })] })), + Match.when( + "Escape", + (): Return => ({ model, commands: [CloseAutocomplete({ editorId: model.editorId })] }), + ), Match.exhaustive, ) }, - HoveredAutocompleteOption: ({ index }) => ({ model: { ...model, activeIndex: index } }), + HoveredAutocompleteOption: ({ index }) => + withOptionSync(model, modifyFields(model, { activeIndex: () => index })), ClickedAutocompleteOption: ({ index }) => select(model, index), - UpdatedMentionMembers: ({ members }) => withOptionSync(model, { ...model, members }), - UpdatedPresence: ({ presence }) => ({ model: { ...model, presence } }), - UpdatedMentionableBots: ({ bots }) => withOptionSync(model, { ...model, mentionableBots: bots }), - UpdatedBotCommands: ({ commands }) => withOptionSync(model, { ...model, botCommands: commands }), - UpdatedCustomEmojis: ({ emojis }) => withOptionSync(model, { ...model, customEmojis: emojis }), - UpdatedCommandValue: ({ argName, value }) => - model.commandInput === null - ? { model } - : { - model: { - ...model, - commandInput: { ...model.commandInput, values: { ...model.commandInput.values, [argName]: value } }, - }, - }, - FocusedCommandField: ({ index }) => - model.commandInput === null - ? { model } - : { model: { ...model, commandInput: { ...model.commandInput, focusedFieldIndex: index } } }, + UpdatedMentionMembers: ({ members }) => + withOptionSync(model, modifyFields(model, { members: () => members })), + UpdatedPresence: ({ presence }) => ({ model: modifyFields(model, { presence: () => presence }) }), + UpdatedMentionableBots: ({ bots }) => + withOptionSync(model, modifyFields(model, { mentionableBots: () => bots })), + UpdatedBotCommands: ({ commands }) => + withOptionSync(model, modifyFields(model, { botCommands: () => commands })), + UpdatedCustomEmojis: ({ emojis }) => + withOptionSync(model, modifyFields(model, { customEmojis: () => emojis })), + UpdatedCommandValue: ({ argName, value }) => ({ + model: modifyCommandInput(model, (input) => + modifyFields(input, { values: (values) => ({ ...values, [argName]: value }) }), + ), + }), // `CommandInputPanel.handleKeyDown`: Tab moves between fields; Enter and Escape reach the draft. PressedCommandFieldKey: ({ index, key, shiftKey }) => { - const last = (model.commandInput?.command.arguments.length ?? 1) - 1 - if (key === "Tab") return focusField(model, shiftKey ? Math.max(0, index - 1) : Math.min(last, index + 1)) + if (model.commandInput === null) return { model } + const last = model.commandInput.command.arguments.length - 1 + if (key === "Tab") { + const next = shiftKey ? Math.max(0, index - 1) : Math.min(last, index + 1) + return { model, commands: [FocusCommandField({ editorId: model.editorId, index: next })] } + } return key === "Escape" ? cancelCommand(model) : { model } }, // The draft executes (it knows the organization and channel). ClickedExecuteCommand: () => ({ model }), ClickedCancelCommand: () => cancelCommand(model), - // The draft (`draft-update.ts`) acts on these; a bare composer (the gallery) ignores them. + // The draft (`draft-update.ts`) acts on these; the bare composer ignores them. SubmittedDraft: () => ({ model }), PressedEscape: () => ({ model }), PressedArrowUpInEmpty: () => ({ model }), PastedFiles: () => ({ model }), - CompletedInsertMention: () => ({ model }), - CompletedSyncAutocompleteOptions: () => ({ model }), - CompletedCloseAutocomplete: () => ({ model }), - CompletedKeepEditorFocus: () => ({ model }), - CompletedSetEditorContent: () => ({ model }), - CompletedClearEditor: () => ({ model }), - CompletedFocusEditor: () => ({ model }), - CompletedInsertEditorText: () => ({ model }), - CompletedInsertCustomEmoji: () => ({ model }), - CompletedInsertEmoji: () => ({ model }), - CompletedRemoveTriggerText: () => ({ model }), - CompletedFocusCommandField: () => ({ model }), + CompletedPreventEditorBlur: () => noChange(model), + CompletedFitAutocompleteHeight: () => noChange(model), + CompletedInsertMention: () => noChange(model), + CompletedSyncAutocompleteOptions: () => noChange(model), + CompletedCloseAutocomplete: () => noChange(model), + CompletedSetEditorContent: () => noChange(model), + CompletedClearEditor: () => noChange(model), + CompletedFocusEditor: () => noChange(model), + CompletedInsertEditorText: () => noChange(model), + CompletedInsertCustomEmoji: () => noChange(model), + CompletedInsertEmoji: () => noChange(model), + CompletedRemoveTriggerText: () => noChange(model), + CompletedFocusCommandField: () => noChange(model), + // The Model already holds the draft (`setDraft`, `clearDraft`), and an unmounted editor has no + // autocomplete or caret to act on; the next mount starts from the Model. + FailedInsertMention: () => noChange(model), + FailedSyncAutocompleteOptions: () => noChange(model), + FailedCloseAutocomplete: () => noChange(model), + FailedSetEditorContent: () => noChange(model), + FailedClearEditor: () => noChange(model), + FailedInsertEditorText: () => noChange(model), + FailedInsertCustomEmoji: () => noChange(model), + FailedInsertEmoji: () => noChange(model), + FailedRemoveTriggerText: () => noChange(model), }) diff --git a/apps/web-foldkit/src/composer/upload.test.ts b/apps/web-foldkit/src/composer/upload.test.ts new file mode 100644 index 000000000..b67688e37 --- /dev/null +++ b/apps/web-foldkit/src/composer/upload.test.ts @@ -0,0 +1,128 @@ +import { AttachmentId, ChannelId, OrganizationId } from "@hazel/schema" +import { Effect, Fiber, Layer, Schema, Stream } from "effect" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { HazelApiClient, HazelRpc } from "../rpc" +import { UploadEvent, uploadStream } from "./upload" + +/** The upload Stream against a fake XHR and fake clients: how it ends, and that stopping it aborts (P0). */ + +const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` +const attachmentId = Schema.decodeSync(AttachmentId)(uuid(7001)) +const args = { + fileId: "f1", + file: new File(["png"], "diagram.png", { type: "image/png" }), + organizationId: Schema.decodeSync(OrganizationId)(uuid(4)), + channelId: Schema.decodeSync(ChannelId)(uuid(1)), +} + +/** An XHR that does nothing until the test settles it. */ +class FakeXhr { + static last: FakeXhr | null = null + timeout = 0 + status = 0 + isAborted = false + isSent = false + upload: { + onprogress: ((event: { lengthComputable: boolean; loaded: number; total: number }) => void) | null + } = { + onprogress: null, + } + onload: (() => void) | null = null + onerror: (() => void) | null = null + ontimeout: (() => void) | null = null + constructor() { + FakeXhr.last = this + } + open() {} + setRequestHeader() {} + send() { + this.isSent = true + } + abort() { + this.isAborted = true + } +} + +const rpcCalls: Array = [] + +const clients = Layer.mergeAll( + Layer.succeed(HazelApiClient, { + uploads: { + presign: () => + Effect.succeed({ uploadUrl: "https://storage.test/put", resourceId: attachmentId, key: "k" }), + }, + } as never), + Layer.succeed(HazelRpc, ((name: string, payload: unknown) => + Effect.sync(() => { + rpcCalls.push([name, payload]) + })) as never), +) + +const waitForSend = Effect.gen(function* () { + while (FakeXhr.last?.isSent !== true) yield* Effect.sleep("1 millis") + return FakeXhr.last +}) + +beforeEach(() => { + FakeXhr.last = null + rpcCalls.length = 0 + vi.stubGlobal("XMLHttpRequest", FakeXhr) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("uploadStream", () => { + test("a stored file is completed and reported with its attachment", async () => { + const events = await Effect.gen(function* () { + const fiber = yield* uploadStream(args).pipe(Stream.take(2), Stream.runCollect, Effect.forkChild) + const xhr = yield* waitForSend + xhr.upload.onprogress?.({ lengthComputable: true, loaded: 1, total: 2 }) + xhr.status = 200 + xhr.onload?.() + return yield* Fiber.join(fiber) + }).pipe(Effect.provide(clients), Effect.runPromise) + expect(events).toEqual([ + UploadEvent.ProgressedUpload({ fileId: "f1", progress: 50 }), + UploadEvent.SucceededUpload({ fileId: "f1", attachmentId }), + ]) + expect(rpcCalls).toEqual([["attachment.complete", { id: attachmentId }]]) + }) + + test("a network failure is a typed reason, and the attachment is marked failed", async () => { + const events = await Effect.gen(function* () { + const fiber = yield* uploadStream(args).pipe(Stream.take(1), Stream.runCollect, Effect.forkChild) + const xhr = yield* waitForSend + xhr.onerror?.() + return yield* Fiber.join(fiber) + }).pipe(Effect.provide(clients), Effect.runPromise) + expect(events).toEqual([UploadEvent.FailedUpload({ fileId: "f1", reason: "StorageNetwork" })]) + expect(rpcCalls).toEqual([ + ["attachment.fail", { id: attachmentId, reason: "Storage upload failed: StorageNetwork" }], + ]) + }) + + test("stopping the Stream mid-upload aborts the request and marks the attachment cancelled", async () => { + const xhr = await Effect.gen(function* () { + const fiber = yield* uploadStream(args).pipe(Stream.runDrain, Effect.forkChild) + const sent = yield* waitForSend + yield* Fiber.interrupt(fiber) + return sent + }).pipe(Effect.provide(clients), Effect.runPromise) + expect(xhr.isAborted).toBe(true) + expect(rpcCalls).toEqual([["attachment.fail", { id: attachmentId, reason: "Upload cancelled" }]]) + }) + + test("a file over the limit fails before anything is uploaded", async () => { + const big = { ...args, file: new File([new Uint8Array(10 * 1024 * 1024 + 1)], "big.bin") } + const events = await uploadStream(big).pipe( + Stream.take(1), + Stream.runCollect, + Effect.provide(clients), + Effect.runPromise, + ) + expect(events).toEqual([UploadEvent.FailedUpload({ fileId: "f1", reason: "FileTooLarge" })]) + expect(FakeXhr.last).toBeNull() + }) +}) diff --git a/apps/web-foldkit/src/composer/upload.ts b/apps/web-foldkit/src/composer/upload.ts index 2d3d0fa74..bf6ec03e2 100644 --- a/apps/web-foldkit/src/composer/upload.ts +++ b/apps/web-foldkit/src/composer/upload.ts @@ -1,27 +1,39 @@ -import { AttachmentId, ChannelId, OrganizationId } from "@hazel/schema" -import { Effect, Exit, Queue, Schema, Stream } from "effect" +import { AttachmentId, type ChannelId, type OrganizationId } from "@hazel/schema" +import { Duration, Effect, Queue, Schema, Stream } from "effect" import { defineMessageUnion } from "foldkit/message" -import { HazelApiClient } from "~/lib/services/common/atom-client" -import { type UploadErrorType, uploadErrorMessages, uploadToStorage } from "~/lib/upload-to-storage" -import { ToastRequest } from "../overlay/toasts" -import { HazelRpc } from "../rpc" -import { errorToast, runAtomFn } from "../data/actions" +import { CommonReason } from "~/lib/error-messages" +import { catchCommon } from "../data/actions" +import { HazelApiClient, HazelRpc } from "../rpc" /** * Port of `useFileUpload().uploadFile`: presign (`uploads.presign`), PUT with progress, then * `attachment.complete`, or `attachment.fail` with the legacy reason. Runs as a Stream so the - * progress reaches the Model; the last event says how it ended. + * progress reaches the Model; the last event says how it ended. Stopping the Stream (a channel + * switch, a closed thread) aborts the PUT and marks the attachment failed. */ export const MAX_FILE_SIZE = 10 * 1024 * 1024 +/** What can go wrong with one upload, beyond the shared reasons. */ +export const UploadFailure = Schema.Union([ + Schema.Literals([ + "FileTooLarge", + /** The server refused to presign (quota, unknown organization). */ + "Rejected", + "StorageNetwork", + "StorageTimeout", + "StorageServer", + /** The file was stored but its attachment could not be completed. */ + "NotCompleted", + ]), + CommonReason, +]) +export type UploadFailure = typeof UploadFailure.Type + export const UploadEvent = defineMessageUnion({ ProgressedUpload: { fileId: Schema.String, progress: Schema.Number }, - FinishedUpload: { - fileId: Schema.String, - attachmentId: Schema.NullOr(AttachmentId), - toast: Schema.NullOr(ToastRequest), - }, + SucceededUpload: { fileId: Schema.String, attachmentId: AttachmentId }, + FailedUpload: { fileId: Schema.String, reason: UploadFailure }, }) export type UploadEvent = typeof UploadEvent.Type @@ -32,79 +44,105 @@ export interface UploadArgs { readonly channelId: ChannelId } -const presign = HazelApiClient.mutation("uploads", "presign") +const STORAGE_TIMEOUT_MS = 120_000 -const markFailed = (id: AttachmentId, reason: string) => - Effect.gen(function* () { - const client = yield* HazelRpc - yield* client("attachment.fail", { id, reason }) - }).pipe(Effect.ignore) +class StorageUploadError extends Schema.TaggedError()("StorageUploadError", { + reason: Schema.Literals(["StorageNetwork", "StorageTimeout", "StorageServer"]), +}) {} -const upload = (args: UploadArgs, onProgress: (percent: number) => void) => - Effect.gen(function* () { - const { file } = args - if (file.size > MAX_FILE_SIZE) { - return { attachmentId: null, toast: errorToast("File too large", `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`) } - } - const presigned = yield* Effect.exit( - runAtomFn(presign, { - payload: { - type: "attachment" as const, - fileName: file.name, - fileSize: file.size, - contentType: file.type || "application/octet-stream", - organizationId: args.organizationId, - channelId: args.channelId, - }, - }), - ) - if (Exit.isFailure(presigned)) { - return { attachmentId: null, toast: errorToast("Upload failed", "Failed to get upload URL. Please try again.") } - } - const { uploadUrl, resourceId } = presigned.value - if (!resourceId) { - return { - attachmentId: null, - toast: errorToast("Upload failed", "Failed to create attachment record. Please try again."), - } +/** The presigned PUT, with progress. Interrupting it aborts the request. */ +const putToStorage = (uploadUrl: string, file: File, onProgress: (percent: number) => void) => + Effect.callback((resume) => { + const xhr = new XMLHttpRequest() + xhr.timeout = STORAGE_TIMEOUT_MS + const fail = (reason: StorageUploadError["reason"]) => + resume(Effect.fail(new StorageUploadError({ reason }))) + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100)) } - const attachmentId = AttachmentId.make(resourceId) - const stored = yield* Effect.promise(() => uploadToStorage(uploadUrl, file, { timeout: 120000, onProgress })) - if (!stored.success) { - const errorType: UploadErrorType = stored.errorType ?? "server" - if (errorType === "aborted") { - yield* markFailed(attachmentId, "Upload cancelled") - return { attachmentId: null, toast: null } - } - yield* markFailed(attachmentId, `Storage upload failed: ${stored.errorType}`) - return { attachmentId: null, toast: errorToast("Upload failed", uploadErrorMessages[errorType]) } - } - const completed = yield* Effect.exit( - Effect.gen(function* () { - const client = yield* HazelRpc - return yield* client("attachment.complete", { id: attachmentId }) - }), - ) - if (Exit.isFailure(completed)) { - yield* markFailed(attachmentId, "Failed to finalize upload") - return { attachmentId: null, toast: errorToast("Upload failed", "Failed to finalize upload. Please try again.") } - } - return { attachmentId, toast: null } + xhr.onload = () => + xhr.status >= 200 && xhr.status < 300 ? resume(Effect.void) : fail("StorageServer") + xhr.onerror = () => fail("StorageNetwork") + xhr.ontimeout = () => fail("StorageTimeout") + xhr.open("PUT", uploadUrl) + xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream") + xhr.send(file) + return Effect.sync(() => xhr.abort()) }) -/** The upload of one file as events: progress percentages, then `FinishedUpload`. */ -export const uploadStream = (args: UploadArgs): Stream.Stream => - Stream.callback((queue) => - upload(args, (progress) => Queue.offerUnsafe(queue, UploadEvent.ProgressedUpload({ fileId: args.fileId, progress }))).pipe( - Effect.catchCause(() => - Effect.succeed({ - attachmentId: null, - toast: errorToast("Upload failed", "An unexpected error occurred. Please try again."), - }), - ), - Effect.flatMap((result) => - Queue.offer(queue, UploadEvent.FinishedUpload({ fileId: args.fileId, ...result })), - ), +/** `attachment.fail`, so the row does not stay pending. Best-effort: the upload already failed for the user. */ +const markFailed = (id: AttachmentId, reason: string) => + HazelRpc.use((client) => client("attachment.fail", { id, reason })).pipe( + Effect.timeout(Duration.seconds(10)), + Effect.ignore, + ) + +const completeAttachment = (id: AttachmentId) => + HazelRpc.use((client) => client("attachment.complete", { id })) + +/** Store the file for a presigned attachment and complete it; on any failure the attachment is marked failed. */ +const storeAndComplete = ( + args: UploadArgs, + uploadUrl: string, + attachmentId: AttachmentId, + onProgress: (percent: number) => void, +) => + putToStorage(uploadUrl, args.file, onProgress).pipe( + Effect.andThen(completeAttachment(attachmentId)), + Effect.as(UploadEvent.SucceededUpload({ fileId: args.fileId, attachmentId })), + Effect.catchTags({ + StorageUploadError: ({ reason }) => + markFailed(attachmentId, `Storage upload failed: ${reason}`).pipe( + Effect.as(UploadEvent.FailedUpload({ fileId: args.fileId, reason })), + ), + AttachmentNotFoundError: () => + Effect.succeed(UploadEvent.FailedUpload({ fileId: args.fileId, reason: "NotCompleted" })), + }), + // What is left is a common failure of `attachment.complete`. + Effect.tapError(() => markFailed(attachmentId, "Failed to finalize upload")), + Effect.onInterrupt(() => markFailed(attachmentId, "Upload cancelled")), + ) + +const upload = (args: UploadArgs, onProgress: (percent: number) => void) => { + const failed = (reason: UploadFailure) => UploadEvent.FailedUpload({ fileId: args.fileId, reason }) + const { file } = args + if (file.size > MAX_FILE_SIZE) return Effect.succeed(failed("FileTooLarge")) + return HazelApiClient.use((client) => + client.uploads.presign({ + payload: { + type: "attachment" as const, + fileName: file.name, + fileSize: file.size, + contentType: file.type || "application/octet-stream", + organizationId: args.organizationId, + channelId: args.channelId, + }, + }), + ).pipe( + Effect.flatMap(({ uploadUrl, resourceId }) => + // The backend always creates the attachment row for an attachment presign. + resourceId + ? storeAndComplete(args, uploadUrl, AttachmentId.make(resourceId), onProgress) + : Effect.succeed(failed("Unexpected")), + ), + Effect.catchTags({ + UploadError: () => Effect.succeed(failed("Rejected")), + BotNotFoundForUploadError: () => Effect.succeed(failed("Rejected")), + OrganizationNotFoundForUploadError: () => Effect.succeed(failed("Rejected")), + }), + catchCommon(failed), + ) +} + +/** The upload of one file as events: progress percentages, then `SucceededUpload` or `FailedUpload`. */ +export const uploadStream = ( + args: UploadArgs, +): Stream.Stream => + Stream.callback((queue) => + upload(args, (progress) => + Queue.offerUnsafe(queue, UploadEvent.ProgressedUpload({ fileId: args.fileId, progress })), + ).pipe( + Effect.flatMap((result) => Queue.offer(queue, result)), Effect.andThen(Effect.never), ), ) diff --git a/apps/web-foldkit/src/composer/view.scene.test.ts b/apps/web-foldkit/src/composer/view.scene.test.ts new file mode 100644 index 000000000..c912d1a38 --- /dev/null +++ b/apps/web-foldkit/src/composer/view.scene.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { UserId } from "@hazel/schema" +import { Schema } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import * as Scene from "foldkit/scene" +import { describe, test } from "vitest" +import * as Composer from "./composer" +import { update } from "./update" +import { composerBoxView } from "./view" + +/** The bare composer through its view: the editor's mount arguments and the autocomplete's accessibility. */ + +const editorId = "composer-scene" +const config = { + update, + view: (model: Composer.Model, h: HtmlBuilder) => + composerBoxView(h, model, { toMessage: (message) => message }), +} +const grace: Composer.MentionMember = { + userId: Schema.decodeSync(UserId)("00000000-0000-4000-8000-000000000001"), + firstName: "Grace", + lastName: "Hopper", + avatarUrl: null, +} + +describe("composer view", () => { + test("a remounted editor starts from the draft the Model holds", () => { + Scene.scene( + config, + Scene.given({ ...Composer.init(editorId), markdown: "Half-written **reply**" }), + Scene.Mount.expectHas( + Composer.MountEditor({ + editorId, + placeholder: "Type a message...", + initialMarkdown: "Half-written **reply**", + }), + ), + Scene.Mount.resolve( + Composer.MountEditor, + Composer.Message.UpdatedDraft({ markdown: "Half-written **reply**", isEmpty: false }), + ), + ) + }) + + test("the autocomplete is a labelled listbox whose options have ids; no hidden drop zone button", () => { + Scene.scene( + config, + Scene.given({ + ...Composer.init(editorId), + members: [grace], + autocomplete: { trigger: "mention", search: "gra" }, + }), + Scene.Mount.resolveAll( + [ + { name: "MountEditor" }, + Composer.Message.UpdatedDraft({ markdown: "@gra", isEmpty: false }), + ], + [{ name: "FitAutocompleteHeight" }, Composer.Message.CompletedFitAutocompleteHeight()], + [{ name: "PreventEditorBlur" }, Composer.Message.CompletedPreventEditorBlur()], + ), + Scene.expect(Scene.selector(`#${Composer.listboxId(editorId)}`)).toHaveAttr("role", "listbox"), + Scene.expect(Scene.selector(`#${Composer.optionDomId(editorId, 0)}`)).toHaveAttr( + "aria-selected", + "true", + ), + Scene.expect(Scene.role("button", { name: "DropZone" })).toBeAbsent(), + ) + }) +}) diff --git a/apps/web-foldkit/src/composer/view.ts b/apps/web-foldkit/src/composer/view.ts index a333a764b..1af3e536a 100644 --- a/apps/web-foldkit/src/composer/view.ts +++ b/apps/web-foldkit/src/composer/view.ts @@ -1,24 +1,19 @@ import { Mount } from "foldkit" import { type Attribute, type ChildAttribute, childAttributes, type Html, type HtmlBuilder } from "foldkit/html" -import { defineView } from "foldkit/submodel" import { cn } from "~/lib/utils" import { cx } from "~/utils/cx" import { IconEmoji1, IconGif, IconPaperclip2 } from "../icons" +import { ACCEPT_ATTRIBUTE } from "../editor/file-types" import { autocompletePopover } from "./autocomplete-view" import { commandInputPanel } from "./trigger-views" import { type Message, type Model, MountEditor } from "./composer" /** Port of `SlateMessageComposer`: DropZone > (previews, indicators) > Frame > (Editor, Actions). */ -const VISUALLY_HIDDEN = - "border: 0px; clip: rect(0px, 0px, 0px, 0px); clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;" - const PICKER_BUTTON_CLASS = "inline-flex items-center gap-1.5 rounded-xs p-0 font-semibold text-muted-fg text-xs outline-none transition-colors hover:text-fg" -const ACCEPT = "image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv" - -/** What a draft adds around the bare editor; the gallery composer passes none of it. */ +/** What a draft adds around the bare editor. */ export interface BoxExtras { readonly toMessage: (message: Message) => M /** Attachment previews, reply and edit indicators, above the frame. */ @@ -36,20 +31,28 @@ export interface BoxExtras { export type PickerButton = (attributes: ReadonlyArray, overlay: Html) => Html -/** The GIF and Emoji `AriaButton`s (DialogTrigger children). */ +/** The GIF and Emoji `AriaButton`s (DialogTrigger children); the picker renders beside its button, not in it. */ const pickerButton = (h: HtmlBuilder, label: "GIF" | "Emoji"): PickerButton => (attributes, overlay) => - h.button( + h.div( + [h.Class("contents")], [ - ...attributes, - h.Class(PICKER_BUTTON_CLASS), - h.Attribute("data-rac", ""), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("tabindex", "0"), - h.Attribute("type", "button"), + h.button( + [ + ...attributes, + h.Class(PICKER_BUTTON_CLASS), + h.Attribute("data-react-aria-pressable", "true"), + h.Attribute("tabindex", "0"), + h.Attribute("type", "button"), + ], + [ + (label === "GIF" ? IconGif : IconEmoji1)(h, { className: "size-4 text-muted-fg" }), + label, + ], + ), + overlay, ], - [(label === "GIF" ? IconGif : IconEmoji1)(h, { className: "size-4 text-muted-fg" }), label, overlay], ) const closedPicker = @@ -63,7 +66,7 @@ const actions = (h: HtmlBuilder, extras: BoxExtras): Array => [ h.Attribute("type", "file"), h.Attribute("multiple", ""), h.Class("hidden"), - h.Attribute("accept", ACCEPT), + h.Attribute("accept", ACCEPT_ATTRIBUTE), h.Attribute("aria-label", "File upload"), ...(extras.fileInputAttributes ?? []), ]), @@ -94,19 +97,8 @@ const actions = (h: HtmlBuilder, extras: BoxExtras): Array => [ /** The whole composer box for a composer Model, in the parent's Message type. */ export const composerBoxView = (h: HtmlBuilder, model: Model, extras: BoxExtras): Html => h.div( - [h.Class("relative"), h.Attribute("data-rac", ""), ...(extras.dropZoneAttributes ?? [])], + [h.Class("relative"), ...(extras.dropZoneAttributes ?? [])], [ - h.div( - [h.Attribute("style", VISUALLY_HIDDEN)], - [ - h.button([ - h.Attribute("aria-label", "DropZone"), - h.Attribute("data-react-aria-pressable", "true"), - h.Attribute("tabindex", "0"), - h.Attribute("type", "button"), - ]), - ], - ), h.div( [h.Class("relative flex h-max items-center gap-3")], [ @@ -127,16 +119,33 @@ export const composerBoxView = (h: HtmlBuilder, model: Model, extras: BoxE [ h.div( // Command input replaces the editor in legacy; ours stays mounted, hidden. - [h.Class(cx("relative w-full", model.commandInput !== null && "[&>[role=combobox]]:hidden"))], + [ + h.Class( + cx( + "relative w-full", + model.commandInput !== null && + "[&>[role=combobox]]:hidden", + ), + ), + ], [ model.commandInput === null ? autocompletePopover(h, model, extras.toMessage) - : commandInputPanel(h, model.editorId, model.commandInput, extras.toMessage), + : commandInputPanel( + h, + model.editorId, + model.commandInput, + extras.toMessage, + ), // ProseMirror owns this element's attributes and children. h.keyed("div")("editor", [ h.OnMount( Mount.mapMessage( - MountEditor({ editorId: model.editorId, placeholder: model.placeholder }), + MountEditor({ + editorId: model.editorId, + placeholder: model.placeholder, + initialMarkdown: model.markdown, + }), extras.toMessage, ), ), @@ -152,8 +161,3 @@ export const composerBoxView = (h: HtmlBuilder, model: Model, extras: BoxE ), ], ) - -const identity = (message: Message) => message - -/** The bare composer (the gallery entry). */ -export const view = defineView((model, h) => composerBoxView(h, model, { toMessage: identity })) diff --git a/apps/web-foldkit/src/composer/window-events.ts b/apps/web-foldkit/src/composer/window-events.ts index f614fa9ca..3e2ff0f12 100644 --- a/apps/web-foldkit/src/composer/window-events.ts +++ b/apps/web-foldkit/src/composer/window-events.ts @@ -1,48 +1,38 @@ -import { Effect, Queue, Stream } from "effect" +import { Option, Stream } from "effect" +import * as Dom from "foldkit/dom" -/** Document-level listeners the composer reacts to (`useGlobalKeyboardFocus`, `useTyping`'s blur). */ +/** Document-level events the composer reacts to (`useGlobalKeyboardFocus`, `useTyping`'s blur). */ -/** Printable keys typed outside inputs, editables and open dialogs; the key is taken over. */ +const isEditableTarget = (target: EventTarget | null) => + target instanceof HTMLElement && + (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.contentEditable === "true") + +/** + * Printable keys typed outside inputs and editables; the key is taken over. The page runs this only + * while none of its dialogs (or a root modal, the palette) is open, so a dialog keeps its keys. + */ export const globalTypingStream = (toMessage: (key: string) => Message): Stream.Stream => - Stream.callback((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const onKeyDown = (event: KeyboardEvent) => { - const target = event.target - if ( - target instanceof HTMLElement && - (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.contentEditable === "true") - ) - return - if (document.querySelector('[role="dialog"]:not([data-react-aria-hidden="true"] *)')) return - if (event.ctrlKey || event.altKey || event.metaKey) return - if (event.key.length !== 1) return - event.preventDefault() - Queue.offerUnsafe(queue, toMessage(event.key)) - } - document.addEventListener("keydown", onKeyDown) - return () => document.removeEventListener("keydown", onKeyDown) - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), - ) + Dom.streamFromEventFilterMapPreventDefault({ + target: () => document, + type: "keydown", + filterMapEvent: (event) => + isEditableTarget(event.target) || + event.ctrlKey || + event.altKey || + event.metaKey || + event.key.length !== 1 + ? Option.none() + : Option.some(toMessage(event.key)), + }) /** The window lost focus or the document was hidden. */ export const leftWindowStream = (message: Message): Stream.Stream => - Stream.callback((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const onBlur = () => Queue.offerUnsafe(queue, message) - const onVisibility = () => { - if (document.visibilityState === "hidden") Queue.offerUnsafe(queue, message) - } - window.addEventListener("blur", onBlur) - document.addEventListener("visibilitychange", onVisibility) - return () => { - window.removeEventListener("blur", onBlur) - document.removeEventListener("visibilitychange", onVisibility) - } - }), - (release) => Effect.sync(release), - ).pipe(Effect.flatMap(() => Effect.never)), + Stream.merge( + Dom.streamFromEvent({ target: () => window, type: "blur", mapEvent: () => message }), + Dom.streamFromEventFilterMap({ + target: () => document, + type: "visibilitychange", + filterMapEvent: () => + document.visibilityState === "hidden" ? Option.some(message) : Option.none(), + }), ) diff --git a/apps/web-foldkit/src/core/atoms/emoji-atoms.ts b/apps/web-foldkit/src/core/atoms/emoji-atoms.ts deleted file mode 100644 index 39766b96d..000000000 --- a/apps/web-foldkit/src/core/atoms/emoji-atoms.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Atom } from "effect/reactivity" -import { Schema } from "effect" -import { platformStorageRuntime } from "~/lib/platform-storage" - -/** - * Default emojis to show when no usage data exists - */ -const DEFAULT_EMOJIS = ["👍", "❤️", "😂", "🔥", "👀", "🎉"] as const - -/** - * Schema for emoji usage data - * Maps emoji string to usage count - */ -const EmojiUsageSchema = Schema.Record(Schema.String, Schema.Number) - -export type EmojiUsage = typeof EmojiUsageSchema.Type - -/** - * Atom that stores emoji usage data in localStorage - */ -export const emojiUsageAtom = Atom.kvs({ - runtime: platformStorageRuntime, - key: "hazel-emoji-usage", - schema: Schema.toCodecIso(EmojiUsageSchema), - defaultValue: () => ({}) as EmojiUsage, -}) - -/** - * Derived atom that computes the top 6 most used emojis - */ -export const topEmojisAtom = Atom.make((get) => { - const emojiUsage = get(emojiUsageAtom) - - // Always return DEFAULT_EMOJIS if no usage data - if (!emojiUsage || Object.keys(emojiUsage).length === 0) { - return [...DEFAULT_EMOJIS] - } - - const entries = Object.entries(emojiUsage) - const sorted = entries.sort((a, b) => b[1] - a[1]) - const topEmojis = sorted.slice(0, 6).map(([emoji]) => emoji) - - // Fill with defaults if we don't have 6 yet - if (topEmojis.length < 6) { - const remainingDefaults = DEFAULT_EMOJIS.filter((emoji) => !topEmojis.includes(emoji)) - return [...topEmojis, ...remainingDefaults].slice(0, 6) - } - - return topEmojis -}).pipe(Atom.keepAlive) - -// trackEmojiUsage is now provided via useEmojiStats hook -// resetEmojiStats is now provided via useEmojiStats hook diff --git a/apps/web-foldkit/src/core/atoms/notification-sound-atoms.test.ts b/apps/web-foldkit/src/core/atoms/notification-sound-atoms.test.ts deleted file mode 100644 index 2ba38d55e..000000000 --- a/apps/web-foldkit/src/core/atoms/notification-sound-atoms.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import { isInQuietHours } from "./notification-sound-atoms" - -describe("notification-sound-atoms", () => { - it("returns false when quiet hours are not configured", () => { - expect(isInQuietHours(null, "08:00")).toBe(false) - expect(isInQuietHours("22:00", null)).toBe(false) - }) - - it("supports minute precision during same-day quiet hours", () => { - vi.useFakeTimers() - vi.setSystemTime(new Date("2025-01-01T10:30:00")) - expect(isInQuietHours("10:15", "10:45")).toBe(true) - expect(isInQuietHours("10:31", "10:45")).toBe(false) - vi.useRealTimers() - }) - - it("supports quiet hours spanning midnight", () => { - vi.useFakeTimers() - vi.setSystemTime(new Date("2025-01-01T23:30:00")) - expect(isInQuietHours("22:15", "06:45")).toBe(true) - vi.setSystemTime(new Date("2025-01-01T12:00:00")) - expect(isInQuietHours("22:15", "06:45")).toBe(false) - vi.useRealTimers() - }) -}) diff --git a/apps/web-foldkit/src/core/atoms/notification-sound-atoms.ts b/apps/web-foldkit/src/core/atoms/notification-sound-atoms.ts deleted file mode 100644 index 377264078..000000000 --- a/apps/web-foldkit/src/core/atoms/notification-sound-atoms.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @module Notification Sound Atoms - * @description Atoms for notification sound system state management - */ - -import { Atom } from "effect/reactivity" -import { Schema } from "effect" -import { platformStorageRuntime } from "~/lib/platform-storage" - -/** - * Session initialization timestamp - notifications before this are "old" - * and should not trigger sounds (prevents sounds on app startup) - */ -export const sessionStartTimeAtom = Atom.make(new Date()).pipe(Atom.keepAlive) - -/** - * Notification sound settings schema and atom - */ -export interface NotificationSoundSettings { - enabled: boolean - volume: number - soundFile: "notification01" | "notification03" - cooldownMs: number -} - -const NotificationSoundSettingsSchema = Schema.Struct({ - enabled: Schema.Boolean, - volume: Schema.Number, - soundFile: Schema.Literals(["notification01", "notification03"]), - cooldownMs: Schema.Number, -}) - -const DEFAULT_SETTINGS: NotificationSoundSettings = { - enabled: true, - volume: 0.5, - soundFile: "notification01", - cooldownMs: 1000, -} - -export const notificationSoundSettingsAtom = Atom.kvs({ - runtime: platformStorageRuntime, - key: "notification-sound-settings", - schema: Schema.toCodecIso(Schema.NullOr(NotificationSoundSettingsSchema)), - defaultValue: () => DEFAULT_SETTINGS, -}) - -const parseTimeToMinutes = (time: string): number => { - const [hours, minutes] = time.split(":").map(Number) - return (hours ?? 0) * 60 + (minutes ?? 0) -} - -/** - * Check if current time is within quiet hours. - * Evaluates at READ time (not subscription time) for accurate results. - */ -export const isInQuietHours = (quietHoursStart: string | null, quietHoursEnd: string | null): boolean => { - if (!quietHoursStart || !quietHoursEnd) { - return false - } - - const now = new Date() - const currentMinutes = now.getHours() * 60 + now.getMinutes() - const start = parseTimeToMinutes(quietHoursStart) - const end = parseTimeToMinutes(quietHoursEnd) - - if (start === end) { - return false - } - - if (start < end) { - return currentMinutes >= start && currentMinutes < end - } - return currentMinutes >= start || currentMinutes < end -} - -/** - * Computed getter that determines if sounds should be muted. - */ -export const createIsMutedGetter = ( - settings: NotificationSoundSettings | null, - doNotDisturb: boolean | null, - quietHoursStart: string | null, - quietHoursEnd: string | null, -): (() => boolean) => { - return () => { - if (!settings?.enabled) { - return true - } - - if (doNotDisturb) { - return true - } - - if (isInQuietHours(quietHoursStart, quietHoursEnd)) { - return true - } - - return false - } -} diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-code-decorator.ts b/apps/web-foldkit/src/core/components/chat/slate-editor/slate-code-decorator.ts deleted file mode 100644 index 7417a5909..000000000 --- a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-code-decorator.ts +++ /dev/null @@ -1,151 +0,0 @@ -import Prism from "prismjs" -import "prismjs/components/prism-bash" -import "prismjs/components/prism-javascript" -import "prismjs/components/prism-json" -import "prismjs/components/prism-python" -import "prismjs/components/prism-typescript" -import { Element, Node, type NodeEntry, type Range, Text } from "slate" -import { type CodeBlockElement, isCodeBlockElement } from "./types" - -// Track which languages have been loaded to avoid duplicate imports -const loadedLanguages = new Set(["bash", "javascript", "json", "python", "typescript", "js", "ts"]) - -// Map of language aliases and their import paths -const languageImports: Record Promise> = { - c: () => import("prismjs/components/prism-c"), - cpp: () => import("prismjs/components/prism-cpp"), - csharp: () => import("prismjs/components/prism-csharp"), - css: () => import("prismjs/components/prism-css"), - diff: () => import("prismjs/components/prism-diff"), - go: () => import("prismjs/components/prism-go"), - graphql: () => import("prismjs/components/prism-graphql"), - java: () => import("prismjs/components/prism-java"), - jsx: () => import("prismjs/components/prism-jsx"), - kotlin: () => import("prismjs/components/prism-kotlin"), - markdown: () => import("prismjs/components/prism-markdown"), - md: () => import("prismjs/components/prism-markdown"), - php: () => import("prismjs/components/prism-php"), - ruby: () => import("prismjs/components/prism-ruby"), - rb: () => import("prismjs/components/prism-ruby"), - rust: () => import("prismjs/components/prism-rust"), - rs: () => import("prismjs/components/prism-rust"), - scss: () => import("prismjs/components/prism-scss"), - sql: () => import("prismjs/components/prism-sql"), - swift: () => import("prismjs/components/prism-swift"), - tsx: () => import("prismjs/components/prism-tsx"), - yaml: () => import("prismjs/components/prism-yaml"), - yml: () => import("prismjs/components/prism-yaml"), -} - -/** - * Dynamically load a Prism language component - * Returns true if the language is now available, false otherwise - */ -export async function loadPrismLanguage(language: string): Promise { - const normalizedLang = language.toLowerCase() - - // Already loaded - if (loadedLanguages.has(normalizedLang) || Prism.languages[normalizedLang]) { - loadedLanguages.add(normalizedLang) - return true - } - - // Try to load the language - const loader = languageImports[normalizedLang] - if (loader) { - try { - await loader() - loadedLanguages.add(normalizedLang) - return true - } catch { - // Failed to load, will fall back to plaintext - return false - } - } - - return false -} - -// Flatten nested tokens into a single-level array with types -interface NormalizedToken { - types: string[] - content: string -} - -function normalizeTokens(tokens: Array): NormalizedToken[] { - const normalized: NormalizedToken[] = [] - - function flatten(token: string | Prism.Token, types: string[] = []): void { - if (typeof token === "string") { - if (token.length > 0) { - normalized.push({ types, content: token }) - } - } else { - const newTypes = types.concat(token.type) - - if (Array.isArray(token.content)) { - for (const child of token.content) { - flatten(child, newTypes) - } - } else { - flatten(token.content, newTypes) - } - } - } - - for (const token of tokens) { - flatten(token) - } - - return normalized -} - -export function decorateCodeBlock([block, blockPath]: NodeEntry): Range[] { - if (!Element.isElement(block) || !isCodeBlockElement(block)) { - return [] - } - - const codeBlock = block as CodeBlockElement - const language = codeBlock.language || "plaintext" - const grammar = Prism.languages[language] - - if (!grammar) { - return [] - } - - // Get all text from code block - const text = codeBlock.children - .map((line) => (Text.isText(line) ? line.text : Node.string(line))) - .join("\n") - - // Tokenize with Prism - const tokens = Prism.tokenize(text, grammar) - const normalizedTokens = normalizeTokens(tokens) - - const decorations: any[] = [] - let offset = 0 - - for (const token of normalizedTokens) { - const length = token.content.length - if (!length) continue - - const end = offset + length - - // Create decoration range with token types as properties - const decoration: any = { - anchor: { path: [...blockPath, 0, 0], offset }, - focus: { path: [...blockPath, 0, 0], offset: end }, - token: true, - } - - // Add each token type as a property - for (const type of token.types) { - decoration[type] = true - } - - decorations.push(decoration) - offset = end - } - - return decorations -} diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-emoji-decorator.ts b/apps/web-foldkit/src/core/components/chat/slate-editor/slate-emoji-decorator.ts deleted file mode 100644 index 9e26fee82..000000000 --- a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-emoji-decorator.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { BaseRange } from "slate" -import { getEmojiShortcode } from "~/lib/emoji-shortcode-map" - -export interface EmojiRange extends BaseRange { - type: "emoji" - emoji: string - shortcode: string -} - -const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) - -/** - * Simple heuristic: a single grapheme cluster is an emoji if it contains - * at least one codepoint in a known emoji range. This avoids false-positives - * on normal letters/digits while catching ZWJ sequences, flags, skin tones, etc. - */ -function isEmojiGrapheme(grapheme: string): boolean { - for (const cp of grapheme) { - const code = cp.codePointAt(0)! - if ( - (code >= 0x1f600 && code <= 0x1f64f) || // Emoticons - (code >= 0x1f300 && code <= 0x1f5ff) || // Misc Symbols & Pictographs - (code >= 0x1f680 && code <= 0x1f6ff) || // Transport & Map - (code >= 0x1f700 && code <= 0x1f77f) || // Alchemical - (code >= 0x1f780 && code <= 0x1f7ff) || // Geometric Shapes Extended - (code >= 0x1f800 && code <= 0x1f8ff) || // Supplemental Arrows-C - (code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental Symbols - (code >= 0x1fa00 && code <= 0x1fa6f) || // Chess Symbols - (code >= 0x1fa70 && code <= 0x1faff) || // Symbols Extended-A - (code >= 0x2600 && code <= 0x26ff) || // Misc symbols - (code >= 0x2700 && code <= 0x27bf) || // Dingbats - (code >= 0x231a && code <= 0x231b) || // Watch, Hourglass - (code >= 0x23e9 && code <= 0x23f3) || // Media controls - (code >= 0x23f8 && code <= 0x23fa) || // More media - code === 0x200d || // ZWJ - code === 0xfe0f || // Variation selector - (code >= 0x2702 && code <= 0x27b0) || // Dingbats - (code >= 0xfe00 && code <= 0xfe0f) || // Variation Selectors - (code >= 0x2b05 && code <= 0x2b07) || // Arrows - (code >= 0x2b1b && code <= 0x2b1c) || // Squares - code === 0x2b50 || // Star - code === 0x2b55 || // Circle - (code >= 0x2934 && code <= 0x2935) || // Arrows - code === 0x25aa || - code === 0x25ab || // Small squares - code === 0x25b6 || - code === 0x25c0 || // Play buttons - (code >= 0x25fb && code <= 0x25fe) || // Medium squares - code === 0x2139 || // Information - (code >= 0x2194 && code <= 0x2199) || // Arrows - (code >= 0x21a9 && code <= 0x21aa) || // Arrows - code === 0x00a9 || - code === 0x00ae || // ©® - code === 0x2122 || // ™ - (code >= 0x2753 && code <= 0x2757) || // Question/exclamation - (code >= 0x2763 && code <= 0x2764) || // Hearts - (code >= 0x2795 && code <= 0x2797) || // Math - code === 0x27a1 || // Arrow - code === 0x27b0 || - code === 0x27bf || // Curly loop - code === 0x2328 || // Keyboard - code === 0x23cf || // Eject - (code >= 0x23ed && code <= 0x23ef) || // Media controls - code === 0x24c2 || // Circled M - code === 0x274c || - code === 0x274e || // Cross marks - (code >= 0x2733 && code <= 0x2734) || // Asterisks - code === 0x2744 || // Snowflake - code === 0x2747 || // Sparkle - code === 0x303d || // Part alternation - code === 0x3030 || // Wavy dash - code === 0x3297 || - code === 0x3299 || // Japanese - (code >= 0x1f1e6 && code <= 0x1f1ff) // Regional indicators (flags) - ) { - return true - } - } - return false -} - -/** - * Slate decorator: identifies standard Unicode emojis in text nodes - * and returns decoration ranges with `{ type: "emoji", emoji, shortcode }`. - * - * Uses Intl.Segmenter for correct grapheme boundary detection (handles - * ZWJ sequences, flags, skin tones, etc.). - * - * Only decorates emojis that have a known shortcode in our map. - * Skips code blocks (caller should pass parentElement). - */ -export function decorateEmoji(entry: [node: any, path: number[]], parentElement?: any): EmojiRange[] { - const [node, path] = entry - const ranges: EmojiRange[] = [] - - if (!node.text) return ranges - - // Skip emoji decoration in code blocks - if (parentElement?.type === "code-block") return ranges - - const text: string = node.text - const segments = segmenter.segment(text) - - for (const { segment, index } of segments) { - if (!isEmojiGrapheme(segment)) continue - - const shortcode = getEmojiShortcode(segment) - if (!shortcode) continue - - ranges.push({ - anchor: { path, offset: index }, - focus: { path, offset: index + segment.length }, - type: "emoji", - emoji: segment, - shortcode, - }) - } - - return ranges -} diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-decorations.ts b/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-decorations.ts deleted file mode 100644 index c899a79c2..000000000 --- a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-decorations.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { BaseRange } from "slate" - -/** - * Markdown decorations for message text (patterns, ranges, leaf classes). Framework-free so the - * React viewer and the Foldkit viewer decorate and style leaves identically. - */ - -// Markdown patterns for Discord-style highlighting -const MARKDOWN_PATTERNS = [ - { - pattern: /(\*\*)([^*]+)(\*\*)/g, - type: "bold" as const, - }, - { - pattern: /(\*)([^*]+)(\*)/g, - type: "italic" as const, - }, - { - pattern: /(~~)([^~]+)(~~)/g, - type: "strikethrough" as const, - }, - { - pattern: /(`)([^`]+)(`)/g, - type: "code" as const, - }, - { - pattern: /(__)([^_]+)(__)/g, - type: "underline" as const, - }, - { - pattern: /(_)([^_]+)(_)/g, - type: "italic" as const, - }, - { - pattern: /(==)([^=]+)(==)/g, - type: "highlight" as const, - }, - { - pattern: /(\|\|)([^|]+)(\|\|)/g, - type: "spoiler" as const, - }, -] as const - -// Link pattern: [text](url) -export const LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g - -// Plain URL pattern: matches http(s):// or www. URLs -export const URL_PATTERN = /(https?:\/\/[^\s<>()]+|www\.[^\s<>()]+)/g - -export type MarkdownDecorationType = (typeof MARKDOWN_PATTERNS)[number]["type"] | "link" | "url" - -export interface MarkdownRange extends BaseRange { - [key: string]: unknown - type: MarkdownDecorationType - isMarker?: boolean - url?: string - linkText?: string -} - -/** - * Decorate text nodes with markdown syntax highlighting - * This makes the markdown tokens visible but styled (Discord-style) - * @param entry - The node and path tuple - * @param parentElement - Optional parent element to check type - */ -export function decorateMarkdown(entry: [node: any, path: number[]], parentElement?: any): MarkdownRange[] { - const [node, path] = entry - const ranges: MarkdownRange[] = [] - - if (!node.text) { - return ranges - } - - // Skip markdown decoration in code blocks - if (parentElement?.type === "code-block") { - return ranges - } - - const text = node.text - - // Decorate markdown links (high priority) - const linkMatches = text.matchAll(LINK_PATTERN) - for (const match of linkMatches) { - if (match.index === undefined) continue - - const fullMatch = match[0] // Full match: [text](url) - const linkText = match[1] // Captured text between [ ] - const url = match[2] // Captured URL between ( ) - - // Mark entire link as a single range with metadata - ranges.push({ - anchor: { path, offset: match.index }, - focus: { path, offset: match.index + fullMatch.length }, - type: "link", - isMarker: false, - url, - linkText, - }) - } - - // Decorate plain URLs (lower priority than markdown links) - const urlMatches = text.matchAll(URL_PATTERN) - for (const match of urlMatches) { - if (match.index === undefined) continue - - const url = match[0] - - // Skip if this URL is part of a markdown link - const overlapsMarkdownLink = ranges.some( - (range) => - range.type === "link" && - match.index !== undefined && - match.index >= range.anchor.offset && - match.index < range.focus.offset, - ) - if (overlapsMarkdownLink) continue - - // Normalize URL (add https:// if it starts with www.) - const normalizedUrl = url.startsWith("www.") ? `https://${url}` : url - - ranges.push({ - anchor: { path, offset: match.index }, - focus: { path, offset: match.index + url.length }, - type: "url", - isMarker: false, - url: normalizedUrl, - }) - } - - // Decorate other markdown patterns - for (const { pattern, type } of MARKDOWN_PATTERNS) { - const matches = text.matchAll(pattern) - - for (const match of matches) { - if (match.index === undefined) continue - - const fullMatch = match[0] - const openMarker = match[1] - const content = match[2] - const closeMarker = match[3] - - // Skip if the markers are escaped or incomplete - if (!openMarker || !content || !closeMarker) continue - - // Skip if this overlaps with a link - const specialOverlap = ranges.some( - (range) => - range.type === "link" && - match.index !== undefined && - match.index < range.focus.offset && - match.index + fullMatch.length > range.anchor.offset, - ) - if (specialOverlap) continue - - // Opening marker range - ranges.push({ - anchor: { path, offset: match.index }, - focus: { path, offset: match.index + openMarker.length }, - type, - isMarker: true, - }) - - // Content range - ranges.push({ - anchor: { path, offset: match.index + openMarker.length }, - focus: { path, offset: match.index + openMarker.length + content.length }, - type, - isMarker: false, - }) - - // Closing marker range - ranges.push({ - anchor: { path, offset: match.index + openMarker.length + content.length }, - focus: { path, offset: match.index + fullMatch.length }, - type, - isMarker: true, - }) - } - } - - return ranges -} - -/** - * Class name for a decorated leaf that is not an emoji, link or URL: Prism token classes for code, - * otherwise the markdown style (markers are hidden in the viewer and dimmed in the composer). - */ -export function markdownLeafClassName(leaf: Record, mode: "composer" | "viewer"): string { - if (leaf.token) { - // Build className from all token types (e.g., "token keyword", "token function") - const tokenClasses: string[] = ["token"] - for (const key in leaf) { - if (key !== "text" && key !== "token" && leaf[key] === true) { - tokenClasses.push(key) - } - } - return tokenClasses.join(" ") - } - - const markdownLeaf = leaf as Partial - const markdownType = markdownLeaf.type - if (!markdownType) return "" - if (markdownLeaf.isMarker) { - return mode === "viewer" ? "hidden" : "text-muted-fg/50 select-none" - } - switch (markdownType) { - case "bold": - return "font-bold" - case "italic": - return "italic" - case "strikethrough": - return "line-through" - case "code": - return "bg-accent/50 rounded px-1 py-0.5 font-mono text-sm" - case "underline": - return "underline" - case "highlight": - return "bg-highlight rounded px-0.5" - case "spoiler": - return "bg-muted blur-sm hover:blur-none transition-all" - default: - return "" - } -} diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.test.ts b/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.test.ts deleted file mode 100644 index f129c2089..000000000 --- a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.test.ts +++ /dev/null @@ -1,1014 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { - createEmptyValue, - deserializeFromMarkdown, - extractMentionsFromMarkdown, - hasMentionPattern, - isValueEmpty, - serializeToMarkdown, -} from "./slate-markdown-serializer" - -const TRUSTED_EMOJI_BASE_URL = "https://emoji-cdn.test" - -describe("slate-markdown-serializer", () => { - beforeEach(() => { - vi.stubEnv("VITE_R2_PUBLIC_URL", TRUSTED_EMOJI_BASE_URL) - }) - - afterEach(() => { - vi.unstubAllEnvs() - }) - - // =========================================== - // DESERIALIZATION - Code Blocks - // =========================================== - describe("deserializeFromMarkdown - code blocks", () => { - it("should parse a simple code block", () => { - const markdown = "```javascript\nconst x = 1\n```" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ - type: "code-block", - language: "javascript", - children: [{ text: "const x = 1" }], - }) - }) - - it("should preserve empty lines in code blocks", () => { - const markdown = "```javascript\nline1\n\nline3\n```" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ - type: "code-block", - language: "javascript", - children: [{ text: "line1\n\nline3" }], - }) - }) - - it("should preserve multiple consecutive empty lines", () => { - const markdown = "```\nline1\n\n\n\nline5\n```" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).children[0].text).toBe("line1\n\n\n\nline5") - }) - - it("should handle code block without language", () => { - const markdown = "```\nsome code\n```" - const result = deserializeFromMarkdown(markdown) - - expect(result[0]).toMatchObject({ - type: "code-block", - language: undefined, - }) - }) - - it("should handle code block at end without closing fence", () => { - const markdown = "```javascript\ncode here" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("code-block") - }) - - it("should handle multi-line code with indentation", () => { - const markdown = "```python\ndef foo():\n if True:\n return 42\n```" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).children[0].text).toBe("def foo():\n if True:\n return 42") - }) - - it("should handle code block followed by paragraph", () => { - const markdown = "```js\ncode\n```\nSome text after" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(2) - expect((result[0] as any).type).toBe("code-block") - expect((result[1] as any).type).toBe("paragraph") - }) - - it("should handle empty code block", () => { - const markdown = "```\n```" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("code-block") - expect((result[0] as any).children[0].text).toBe("") - }) - }) - - // =========================================== - // DESERIALIZATION - Edge Cases - // =========================================== - describe("deserializeFromMarkdown - edge cases", () => { - it("should return empty paragraph for empty string", () => { - const result = deserializeFromMarkdown("") - - expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ - type: "paragraph", - children: [{ text: "" }], - }) - }) - - it("should return empty paragraph for whitespace only", () => { - const result = deserializeFromMarkdown(" \n\t\n ") - - expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ - type: "paragraph", - children: [{ text: "" }], - }) - }) - - it("should skip empty lines between elements", () => { - const markdown = "Line 1\n\nLine 2" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(2) - expect((result[0] as any).type).toBe("paragraph") - expect((result[1] as any).type).toBe("paragraph") - }) - }) - - // =========================================== - // DESERIALIZATION - Blockquotes - // =========================================== - describe("deserializeFromMarkdown - blockquotes", () => { - it("should parse single-line blockquote", () => { - const result = deserializeFromMarkdown("> This is a quote") - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("blockquote") - }) - - it("should merge consecutive > lines into single blockquote", () => { - const markdown = "> Line 1\n> Line 2\n> Line 3" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("blockquote") - expect((result[0] as any).children[0].text).toBe("Line 1\nLine 2\nLine 3") - }) - - it("should parse multi-line blockquote with >>>", () => { - const markdown = ">>> First line\nSecond line\nThird line" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("blockquote") - expect((result[0] as any).children[0].text).toBe("First line\nSecond line\nThird line") - }) - - it("should parse blockquote with inline mention", () => { - const markdown = "> Hello @[userId:123] how are you?" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("blockquote") - // Should have text + mention + text children - expect((result[0] as any).children.length).toBeGreaterThan(1) - }) - - it("should stop > blockquote at non-quote line", () => { - const markdown = "> Quote line\nRegular line" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(2) - expect((result[0] as any).type).toBe("blockquote") - expect((result[1] as any).type).toBe("paragraph") - }) - }) - - // =========================================== - // DESERIALIZATION - Lists - // =========================================== - describe("deserializeFromMarkdown - lists", () => { - it("should parse unordered list with dash", () => { - const result = deserializeFromMarkdown("- Item 1") - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("list-item") - expect((result[0] as any).ordered).toBe(false) - }) - - it("should parse unordered list with asterisk", () => { - const result = deserializeFromMarkdown("* Item 1") - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("list-item") - expect((result[0] as any).ordered).toBe(false) - }) - - it("should parse ordered list", () => { - const result = deserializeFromMarkdown("1. First item") - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("list-item") - expect((result[0] as any).ordered).toBe(true) - }) - - it("should parse ordered list with higher numbers", () => { - const result = deserializeFromMarkdown("42. Item forty-two") - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("list-item") - expect((result[0] as any).ordered).toBe(true) - }) - - it("should parse multiple list items", () => { - const markdown = "- Item 1\n- Item 2\n- Item 3" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(3) - expect((result[0] as any).type).toBe("list-item") - expect((result[1] as any).type).toBe("list-item") - expect((result[2] as any).type).toBe("list-item") - }) - - it("should parse list item with inline mention", () => { - const markdown = "- Talk to @[userId:abc]" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("list-item") - expect((result[0] as any).children.length).toBeGreaterThan(1) - }) - }) - - // =========================================== - // DESERIALIZATION - Subtext - // =========================================== - describe("deserializeFromMarkdown - subtext", () => { - it("should parse subtext", () => { - const result = deserializeFromMarkdown("-# Small text") - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("subtext") - }) - - it("should parse subtext with mention", () => { - const result = deserializeFromMarkdown("-# Note from @[userId:123]") - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("subtext") - expect((result[0] as any).children.length).toBeGreaterThan(1) - }) - }) - - // =========================================== - // DESERIALIZATION - Mentions in Paragraphs - // =========================================== - describe("deserializeFromMarkdown - mentions", () => { - it("should parse paragraph with inline mention", () => { - const markdown = "Hello @[userId:123] world" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - expect((result[0] as any).type).toBe("paragraph") - const children = (result[0] as any).children - expect(children).toHaveLength(3) - expect(children[0]).toMatchObject({ text: "Hello " }) - expect(children[1]).toMatchObject({ type: "mention", userId: "123" }) - expect(children[2]).toMatchObject({ text: " world" }) - }) - - it("should parse paragraph with multiple mentions", () => { - const markdown = "@[userId:abc] and @[userId:def]" - const result = deserializeFromMarkdown(markdown) - - expect(result).toHaveLength(1) - const children = (result[0] as any).children - expect(children.filter((c: any) => c.type === "mention")).toHaveLength(2) - }) - - it("should parse mention at start of text", () => { - const markdown = "@[userId:123] is here" - const result = deserializeFromMarkdown(markdown) - - const children = (result[0] as any).children - expect(children[0]).toMatchObject({ type: "mention", userId: "123" }) - }) - - it("should parse mention at end of text", () => { - const markdown = "Hello @[userId:123]" - const result = deserializeFromMarkdown(markdown) - - const children = (result[0] as any).children - expect(children[children.length - 1]).toMatchObject({ type: "mention", userId: "123" }) - }) - - it("should parse directive mentions", () => { - const markdown = "@[directive:channel] everyone!" - const result = deserializeFromMarkdown(markdown) - - const children = (result[0] as any).children - expect(children[0]).toMatchObject({ type: "mention", userId: "channel" }) - }) - }) - - // =========================================== - // DESERIALIZATION - Mixed Content - // =========================================== - describe("deserializeFromMarkdown - mixed content", () => { - it("should parse document with multiple element types", () => { - const markdown = "Hello world\n> A quote\n- List item\n```js\ncode\n```\n-# Subtext" - const result = deserializeFromMarkdown(markdown) - - expect(result.length).toBeGreaterThanOrEqual(5) - expect((result[0] as any).type).toBe("paragraph") - expect((result[1] as any).type).toBe("blockquote") - expect((result[2] as any).type).toBe("list-item") - expect((result[3] as any).type).toBe("code-block") - expect((result[4] as any).type).toBe("subtext") - }) - }) - - // =========================================== - // SERIALIZATION - Code Blocks - // =========================================== - describe("serializeToMarkdown - code blocks", () => { - it("should serialize code block with language", () => { - const nodes = [ - { - type: "code-block" as const, - language: "typescript", - children: [{ text: "const x: number = 1" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("```typescript\nconst x: number = 1\n```") - }) - - it("should serialize code block without language", () => { - const nodes = [ - { - type: "code-block" as const, - children: [{ text: "some code" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("```\nsome code\n```") - }) - - it("should preserve newlines in code block content", () => { - const nodes = [ - { - type: "code-block" as const, - language: "js", - children: [{ text: "line1\n\nline3" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("```js\nline1\n\nline3\n```") - }) - }) - - // =========================================== - // SERIALIZATION - Mentions - // =========================================== - describe("serializeToMarkdown - mentions", () => { - it("should serialize user mention", () => { - const nodes = [ - { - type: "mention" as const, - userId: "user-123", - displayName: "John", - children: [{ text: "" }] as [{ text: "" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("@[userId:user-123]") - }) - - it("should serialize channel directive mention", () => { - const nodes = [ - { - type: "mention" as const, - userId: "channel", - displayName: "channel", - children: [{ text: "" }] as [{ text: "" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("@[directive:channel]") - }) - - it("should serialize here directive mention", () => { - const nodes = [ - { - type: "mention" as const, - userId: "here", - displayName: "here", - children: [{ text: "" }] as [{ text: "" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("@[directive:here]") - }) - }) - - // =========================================== - // SERIALIZATION - Blockquotes - // =========================================== - describe("serializeToMarkdown - blockquotes", () => { - it("should serialize single-line blockquote", () => { - const nodes = [ - { - type: "blockquote" as const, - children: [{ text: "This is a quote" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("> This is a quote") - }) - - it("should serialize multi-line blockquote with > prefix per line", () => { - const nodes = [ - { - type: "blockquote" as const, - children: [{ text: "Line 1\nLine 2\nLine 3" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("> Line 1\n> Line 2\n> Line 3") - }) - - it("should serialize blockquote with mention", () => { - const nodes = [ - { - type: "blockquote" as const, - children: [ - { text: "Hello " }, - { - type: "mention" as const, - userId: "123", - displayName: "User", - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - - const result = serializeToMarkdown(nodes) - expect(result).toBe("> Hello @[userId:123]") - }) - }) - - // =========================================== - // SERIALIZATION - Lists - // =========================================== - describe("serializeToMarkdown - lists", () => { - it("should serialize unordered list item", () => { - const nodes = [ - { - type: "list-item" as const, - ordered: false, - children: [{ text: "Item content" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("- Item content") - }) - - it("should serialize ordered list item", () => { - const nodes = [ - { - type: "list-item" as const, - ordered: true, - children: [{ text: "First item" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("1. First item") - }) - - it("should serialize list item with mention", () => { - const nodes = [ - { - type: "list-item" as const, - ordered: false, - children: [ - { text: "Talk to " }, - { - type: "mention" as const, - userId: "abc", - displayName: "User", - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - - const result = serializeToMarkdown(nodes) - expect(result).toBe("- Talk to @[userId:abc]") - }) - }) - - // =========================================== - // SERIALIZATION - Subtext - // =========================================== - describe("serializeToMarkdown - subtext", () => { - it("should serialize subtext with -# prefix", () => { - const nodes = [ - { - type: "subtext" as const, - children: [{ text: "Small note" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("-# Small note") - }) - - it("should serialize subtext with mention", () => { - const nodes = [ - { - type: "subtext" as const, - children: [ - { text: "By " }, - { - type: "mention" as const, - userId: "author", - displayName: "Author", - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - - const result = serializeToMarkdown(nodes) - expect(result).toBe("-# By @[userId:author]") - }) - }) - - // =========================================== - // SERIALIZATION - Paragraphs - // =========================================== - describe("serializeToMarkdown - paragraphs", () => { - it("should serialize paragraph with text", () => { - const nodes = [ - { - type: "paragraph" as const, - children: [{ text: "Hello world" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("Hello world") - }) - - it("should serialize paragraph with inline mention", () => { - const nodes = [ - { - type: "paragraph" as const, - children: [ - { text: "Hello " }, - { - type: "mention" as const, - userId: "user-1", - displayName: "User", - children: [{ text: "" }] as [{ text: "" }], - }, - { text: " how are you?" }, - ], - }, - ] as any - - const result = serializeToMarkdown(nodes) - expect(result).toBe("Hello @[userId:user-1] how are you?") - }) - - it("should serialize multiple paragraphs joined by newlines", () => { - const nodes = [ - { - type: "paragraph" as const, - children: [{ text: "First paragraph" }], - }, - { - type: "paragraph" as const, - children: [{ text: "Second paragraph" }], - }, - ] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("First paragraph\nSecond paragraph") - }) - }) - - // =========================================== - // SERIALIZATION - Text Nodes - // =========================================== - describe("serializeToMarkdown - text nodes", () => { - it("should serialize plain text node", () => { - const nodes = [{ text: "Just plain text" }] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("Just plain text") - }) - - it("should serialize multiple text nodes joined", () => { - const nodes = [{ text: "First" }, { text: "Second" }] - - const result = serializeToMarkdown(nodes) - expect(result).toBe("First\nSecond") - }) - }) - - // =========================================== - // ROUND-TRIP TESTS - // =========================================== - describe("round-trip: serialize then deserialize", () => { - it("should preserve code block with empty lines through round-trip", () => { - const original = [ - { - type: "code-block" as const, - language: "python", - children: [{ text: "def foo():\n\n return 42" }], - }, - ] - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).children[0].text).toBe("def foo():\n\n return 42") - }) - - it("should preserve code block with multiple empty lines through round-trip", () => { - const original = [ - { - type: "code-block" as const, - language: "js", - children: [{ text: "a\n\n\nb" }], - }, - ] - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).children[0].text).toBe("a\n\n\nb") - expect((result[0] as any).language).toBe("js") - }) - - it("should preserve blockquote through round-trip", () => { - const original = [ - { - type: "blockquote" as const, - children: [{ text: "A quoted message" }], - }, - ] - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).type).toBe("blockquote") - expect((result[0] as any).children[0].text).toBe("A quoted message") - }) - - it("should preserve unordered list item through round-trip", () => { - const original = [ - { - type: "list-item" as const, - ordered: false, - children: [{ text: "List item content" }], - }, - ] - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).type).toBe("list-item") - expect((result[0] as any).ordered).toBe(false) - }) - - it("should preserve ordered list item through round-trip", () => { - const original = [ - { - type: "list-item" as const, - ordered: true, - children: [{ text: "Ordered item" }], - }, - ] - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).type).toBe("list-item") - expect((result[0] as any).ordered).toBe(true) - }) - - it("should preserve subtext through round-trip", () => { - const original = [ - { - type: "subtext" as const, - children: [{ text: "Small text" }], - }, - ] - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).type).toBe("subtext") - expect((result[0] as any).children[0].text).toBe("Small text") - }) - - it("should preserve paragraph with mention through round-trip", () => { - const original = [ - { - type: "paragraph" as const, - children: [ - { text: "Hello " }, - { - type: "mention" as const, - userId: "test-user", - displayName: "Test", - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).type).toBe("paragraph") - const children = (result[0] as any).children - expect(children[0]).toMatchObject({ text: "Hello " }) - expect(children[1]).toMatchObject({ type: "mention", userId: "test-user" }) - }) - - it("should preserve custom emoji through round-trip", () => { - const trustedHazelUrl = `${TRUSTED_EMOJI_BASE_URL}/emojis/hazel.png` - const original = [ - { - type: "paragraph" as const, - children: [ - { text: "Look " }, - { - type: "custom-emoji" as const, - name: "hazel", - imageUrl: trustedHazelUrl, - children: [{ text: "" }] as [{ text: "" }], - }, - { text: " cool!" }, - ], - }, - ] as any - - const markdown = serializeToMarkdown(original) - expect(markdown).toBe(`Look ![custom-emoji:hazel](${trustedHazelUrl}) cool!`) - - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).type).toBe("paragraph") - const children = (result[0] as any).children - expect(children[0]).toMatchObject({ text: "Look " }) - expect(children[1]).toMatchObject({ - type: "custom-emoji", - name: "hazel", - imageUrl: trustedHazelUrl, - }) - expect(children[2]).toMatchObject({ text: " cool!" }) - }) - - it("should serialize paragraph with only a custom emoji", () => { - const trustedHazelUrl = `${TRUSTED_EMOJI_BASE_URL}/emojis/hazel.png` - const original = [ - { - type: "paragraph" as const, - children: [ - { - type: "custom-emoji" as const, - name: "hazel", - imageUrl: trustedHazelUrl, - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - - const markdown = serializeToMarkdown(original) - expect(markdown).toBe(`![custom-emoji:hazel](${trustedHazelUrl})`) - - // Also verify isValueEmpty recognizes this as non-empty - expect(isValueEmpty(original)).toBe(false) - - // And round-trip works - const result = deserializeFromMarkdown(markdown) - expect((result[0] as any).type).toBe("paragraph") - const children = (result[0] as any).children - expect(children[0]).toMatchObject({ - type: "custom-emoji", - name: "hazel", - imageUrl: trustedHazelUrl, - }) - }) - - it("should preserve custom emoji alongside mentions through round-trip", () => { - const trustedWaveUrl = `${TRUSTED_EMOJI_BASE_URL}/emojis/wave.gif` - const original = [ - { - type: "paragraph" as const, - children: [ - { - type: "mention" as const, - userId: "user1", - displayName: "User", - children: [{ text: "" }] as [{ text: "" }], - }, - { text: " " }, - { - type: "custom-emoji" as const, - name: "wave", - imageUrl: trustedWaveUrl, - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - - const markdown = serializeToMarkdown(original) - const result = deserializeFromMarkdown(markdown) - - const children = (result[0] as any).children - expect(children[0]).toMatchObject({ type: "mention", userId: "user1" }) - expect(children[1]).toMatchObject({ text: " " }) - expect(children[2]).toMatchObject({ - type: "custom-emoji", - name: "wave", - imageUrl: trustedWaveUrl, - }) - }) - - it("should downgrade untrusted custom emoji URLs to shortcode text", () => { - const markdown = "Look ![custom-emoji:hazel](https://evil.example/emojis/hazel.png) cool!" - const result = deserializeFromMarkdown(markdown) - - expect((result[0] as any).type).toBe("paragraph") - const children = (result[0] as any).children - expect(children[0]).toMatchObject({ text: "Look " }) - expect(children[1]).toMatchObject({ text: ":hazel:" }) - expect(children[2]).toMatchObject({ text: " cool!" }) - }) - }) - - // =========================================== - // UTILITY FUNCTIONS - // =========================================== - describe("createEmptyValue", () => { - it("should create empty paragraph", () => { - const result = createEmptyValue() - - expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ - type: "paragraph", - children: [{ text: "" }], - }) - }) - }) - - describe("isValueEmpty", () => { - it("should return true for empty value", () => { - expect(isValueEmpty(createEmptyValue())).toBe(true) - }) - - it("should return true for whitespace only", () => { - const value = [{ type: "paragraph" as const, children: [{ text: " " }] }] - expect(isValueEmpty(value)).toBe(true) - }) - - it("should return false for content", () => { - const value = [{ type: "paragraph" as const, children: [{ text: "hello" }] }] - expect(isValueEmpty(value)).toBe(false) - }) - - it("should return true for empty array", () => { - expect(isValueEmpty([])).toBe(true) - }) - - it("should return false for code block with content", () => { - const value = [ - { - type: "code-block" as const, - children: [{ text: "code" }], - }, - ] - expect(isValueEmpty(value)).toBe(false) - }) - - it("should return true for code block with empty content", () => { - const value = [ - { - type: "code-block" as const, - children: [{ text: "" }], - }, - ] - expect(isValueEmpty(value)).toBe(true) - }) - - it("should return false for mention-only content", () => { - const value = [ - { - type: "paragraph" as const, - children: [ - { - type: "mention" as const, - userId: "user-1", - displayName: "User 1", - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - expect(isValueEmpty(value)).toBe(false) - }) - - it("should return false for custom-emoji-only content", () => { - const value = [ - { - type: "paragraph" as const, - children: [ - { - type: "custom-emoji" as const, - name: "hazel", - imageUrl: `${TRUSTED_EMOJI_BASE_URL}/emojis/hazel.png`, - children: [{ text: "" }] as [{ text: "" }], - }, - ], - }, - ] as any - expect(isValueEmpty(value)).toBe(false) - }) - }) - - describe("extractMentionsFromMarkdown", () => { - it("should extract user mentions", () => { - const result = extractMentionsFromMarkdown("Hello @[userId:123] how are you?") - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ prefix: "userId", value: "123" }) - }) - - it("should extract directive mentions", () => { - const result = extractMentionsFromMarkdown("@[directive:channel] everyone!") - - expect(result).toHaveLength(1) - expect(result[0]).toEqual({ prefix: "directive", value: "channel" }) - }) - - it("should extract multiple mentions", () => { - const result = extractMentionsFromMarkdown("@[userId:abc] and @[userId:def]") - - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ prefix: "userId", value: "abc" }) - expect(result[1]).toEqual({ prefix: "userId", value: "def" }) - }) - - it("should return empty array for no mentions", () => { - const result = extractMentionsFromMarkdown("No mentions here") - - expect(result).toHaveLength(0) - }) - - it("should extract mentions from complex text", () => { - const result = extractMentionsFromMarkdown( - "Hey @[userId:user1], please talk to @[directive:channel] about @[userId:user2]", - ) - - expect(result).toHaveLength(3) - }) - }) - - describe("hasMentionPattern", () => { - it("should return true for text with user mentions", () => { - expect(hasMentionPattern("Hello @[userId:123]")).toBe(true) - }) - - it("should return true for text with directive mentions", () => { - expect(hasMentionPattern("Hello @[directive:channel]")).toBe(true) - }) - - it("should return false for text without mentions", () => { - expect(hasMentionPattern("Hello world")).toBe(false) - }) - - it("should return false for malformed mentions", () => { - expect(hasMentionPattern("Hello @[invalid:123]")).toBe(false) - expect(hasMentionPattern("Hello @userId:123]")).toBe(false) - expect(hasMentionPattern("Hello @[userId:123")).toBe(false) - }) - }) -}) diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.ts b/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.ts deleted file mode 100644 index 477f6f05f..000000000 --- a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-markdown-serializer.ts +++ /dev/null @@ -1,608 +0,0 @@ -import { Node } from "slate" - -// Define our custom element and text types -export interface ParagraphElement { - type: "paragraph" - children: CustomText[] -} - -export interface BlockquoteElement { - type: "blockquote" - children: CustomText[] -} - -export interface CodeBlockElement { - type: "code-block" - language?: string - children: CustomText[] -} - -export interface SubtextElement { - type: "subtext" - children: CustomText[] -} - -export interface ListItemElement { - type: "list-item" - ordered?: boolean - children: CustomText[] -} - -export interface MentionElement { - type: "mention" - userId: string - displayName: string - children: [{ text: "" }] -} - -export interface CustomEmojiElement { - type: "custom-emoji" - name: string - imageUrl: string - children: [{ text: "" }] -} - -export interface TableElement { - type: "table" - children: TableRowElement[] -} - -export interface TableRowElement { - type: "table-row" - children: TableCellElement[] -} - -export interface TableCellElement { - type: "table-cell" - header?: boolean - align?: "left" | "center" | "right" - children: CustomDescendant[] -} - -export interface HeadingElement { - type: "heading" - level: 1 | 2 | 3 - children: CustomText[] -} - -export interface CustomText { - text: string -} - -export type CustomElement = - | ParagraphElement - | BlockquoteElement - | CodeBlockElement - | SubtextElement - | ListItemElement - | MentionElement - | CustomEmojiElement - | TableElement - | TableRowElement - | TableCellElement - | HeadingElement -export type CustomDescendant = CustomElement | CustomText - -const TRUSTED_CUSTOM_EMOJI_PATH_SEGMENT = "/emojis/" - -function normalizePathPrefix(pathname: string): string { - return pathname.endsWith("/") ? pathname : `${pathname}/` -} - -/** - * Accept only custom emoji URLs from our configured storage base. - * Inline markdown-provided URLs are otherwise treated as untrusted content. - */ -function isTrustedCustomEmojiUrl(rawUrl: string): boolean { - const customEmojiStorageBaseUrl = import.meta.env.VITE_R2_PUBLIC_URL - if (!customEmojiStorageBaseUrl) { - return false - } - - try { - const candidate = new URL(rawUrl) - const trustedBase = new URL(customEmojiStorageBaseUrl) - - if (candidate.protocol !== "https:" && candidate.protocol !== "http:") { - return false - } - - if (candidate.origin !== trustedBase.origin) { - return false - } - - const trustedPathPrefix = normalizePathPrefix(trustedBase.pathname) - return ( - candidate.pathname.startsWith(trustedPathPrefix) && - candidate.pathname.includes(TRUSTED_CUSTOM_EMOJI_PATH_SEGMENT) - ) - } catch { - return false - } -} - -/** - * Extract mentions from markdown text - * Returns array of { prefix, value } for each mention found - */ -export function extractMentionsFromMarkdown( - markdown: string, -): Array<{ prefix: "userId" | "directive"; value: string }> { - const mentions: Array<{ prefix: "userId" | "directive"; value: string }> = [] - const pattern = /@\[(userId|directive):([^\]]+)\]/g - let match: RegExpExecArray | null - - // biome-ignore lint/suspicious/noAssignInExpressions: regex matching pattern - while ((match = pattern.exec(markdown)) !== null) { - const prefix = match[1] as "userId" | "directive" - const value = match[2] - - if (prefix && value) { - mentions.push({ - prefix, - value, - }) - } - } - - return mentions -} - -/** - * Check if text contains mention pattern - */ -export function hasMentionPattern(text: string): boolean { - return /@\[(userId|directive):([^\]]+)\]/.test(text) -} - -/** - * Serialize Slate value to plain markdown string - * This converts the editor content to markdown that can be sent to the backend - */ -export function serializeToMarkdown(nodes: CustomDescendant[]): string { - return nodes - .map((node) => { - if ("text" in node) { - return node.text - } - - const element = node as CustomElement - - switch (element.type) { - case "custom-emoji": { - const ce = element as CustomEmojiElement - return `![custom-emoji:${ce.name}](${ce.imageUrl})` - } - case "mention": { - // Serialize mention back to markdown syntax - const mentionElement = element as MentionElement - const isSpecialMention = - mentionElement.userId === "channel" || mentionElement.userId === "here" - return isSpecialMention - ? `@[directive:${mentionElement.userId}]` - : `@[userId:${mentionElement.userId}]` - } - case "paragraph": { - // For paragraphs, we need to serialize children which might include mentions - const text = element.children.map((child) => serializeToMarkdown([child])).join("") - return text - } - case "blockquote": { - const text = element.children.map((child) => serializeToMarkdown([child])).join("") - // Prefix each line with "> " - return text - .split("\n") - .map((line) => `> ${line}`) - .join("\n") - } - case "code-block": { - const text = Node.string(element) - // Wrap in triple backticks with optional language - const lang = element.language || "" - return `\`\`\`${lang}\n${text}\n\`\`\`` - } - case "subtext": { - const text = element.children.map((child) => serializeToMarkdown([child])).join("") - // Prefix with -# - return `-# ${text}` - } - case "list-item": { - const text = element.children.map((child) => serializeToMarkdown([child])).join("") - // Prefix with - for unordered or number for ordered - return element.ordered ? `1. ${text}` : `- ${text}` - } - case "table": { - const tableElement = element as TableElement - const rows = tableElement.children - if (rows.length === 0) return "" - - const lines: string[] = [] - for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { - const row = rows[rowIndex] - if (!row) continue - const cells = row.children - const cellTexts = cells.map((cell) => - cell.children.map((child) => serializeToMarkdown([child])).join(""), - ) - lines.push(`| ${cellTexts.join(" | ")} |`) - - // After the header row (first row), add separator - if (rowIndex === 0) { - const separators = cells.map((cell) => { - const align = cell.align - if (align === "center") return ":---:" - if (align === "right") return "---:" - return "---" - }) - lines.push(`| ${separators.join(" | ")} |`) - } - } - return lines.join("\n") - } - case "table-row": - case "table-cell": { - // Rows and cells are handled by table parent - return "" - } - case "heading": { - const headingElement = element as HeadingElement - const text = headingElement.children.map((child) => serializeToMarkdown([child])).join("") - const prefix = "#".repeat(headingElement.level) - return `${prefix} ${text}` - } - default: { - const text = Node.string(element) - return text - } - } - }) - .join("\n") -} - -/** - * Parse inline mentions and custom emojis into a mixed array of text, mention, and custom-emoji nodes - */ -function parseInlineContent(text: string): Array { - const nodes: Array = [] - // Combined pattern: mentions (@[prefix:value]) or custom emojis (![custom-emoji:name](url)) - const inlinePattern = /@\[(userId|directive):([^\]]+)\]|!\[custom-emoji:([^\]]+)\]\(([^)]+)\)/g - let lastIndex = 0 - let match: RegExpExecArray | null - - // biome-ignore lint/suspicious/noAssignInExpressions: regex matching pattern - while ((match = inlinePattern.exec(text)) !== null) { - // Add text before the match - if (match.index > lastIndex) { - nodes.push({ text: text.slice(lastIndex, match.index) }) - } - - if (match[1] && match[2]) { - // Mention match - nodes.push({ - type: "mention", - userId: match[2], - displayName: match[2], - children: [{ text: "" }], - }) - } else if (match[3] && match[4]) { - // Custom emoji match - const emojiName = match[3] - const imageUrl = match[4] - - if (isTrustedCustomEmojiUrl(imageUrl)) { - nodes.push({ - type: "custom-emoji", - name: emojiName, - imageUrl, - children: [{ text: "" }], - }) - } else { - // Untrusted custom emoji URLs are downgraded to shortcode text. - nodes.push({ text: `:${emojiName}:` }) - } - } - - lastIndex = match.index + match[0].length - } - - // Add remaining text after last match - if (lastIndex < text.length) { - nodes.push({ text: text.slice(lastIndex) }) - } - - // If no inline elements found, return the text as-is - if (nodes.length === 0) { - nodes.push({ text }) - } - - return nodes -} - -/** - * Check if a line looks like a GFM table row - */ -function isTableRow(line: string): boolean { - return line.startsWith("|") && line.endsWith("|") -} - -/** - * Check if a line is a GFM table separator row (e.g., |---|:---:|---:|) - */ -function isTableSeparator(line: string): boolean { - if (!isTableRow(line)) return false - // Remove outer pipes and check if all cells are separator patterns - const inner = line.slice(1, -1) - const cells = inner.split("|") - return cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell)) -} - -/** - * Parse alignment from a separator cell (e.g., :---:, ---:, :---, ---) - */ -function parseAlignment(separator: string): "left" | "center" | "right" | undefined { - const trimmed = separator.trim() - const leftColon = trimmed.startsWith(":") - const rightColon = trimmed.endsWith(":") - - if (leftColon && rightColon) return "center" - if (rightColon) return "right" - if (leftColon) return "left" - return undefined -} - -/** - * Parse a table row into cell contents - */ -function parseTableRow(line: string): string[] { - // Remove outer pipes and split by | - const inner = line.slice(1, -1) - return inner.split("|").map((cell) => cell.trim()) -} - -/** - * Parse a GFM table from lines starting at index i - * Returns the table element and the new index after the table - */ -function parseTable(lines: string[], startIndex: number): { table: TableElement; endIndex: number } | null { - const tableLines: string[] = [] - let i = startIndex - - // Collect all consecutive table rows - while (i < lines.length && lines[i] && isTableRow(lines[i]!)) { - tableLines.push(lines[i]!) - i++ - } - - // Need at least 2 lines (header + separator) - if (tableLines.length < 2) return null - - // Second line must be separator - if (!isTableSeparator(tableLines[1]!)) return null - - // Parse alignments from separator row - const separatorCells = parseTableRow(tableLines[1]!) - const alignments = separatorCells.map(parseAlignment) - - // Parse header row - const headerCells = parseTableRow(tableLines[0]!) - const headerRow: TableRowElement = { - type: "table-row", - children: headerCells.map((cell, idx) => ({ - type: "table-cell" as const, - header: true, - align: alignments[idx], - children: parseInlineContent(cell) as CustomDescendant[], - })), - } - - // Parse data rows (skip header and separator) - const dataRows: TableRowElement[] = tableLines.slice(2).map((line) => { - const cells = parseTableRow(line) - return { - type: "table-row" as const, - children: cells.map((cell, idx) => ({ - type: "table-cell" as const, - header: false, - align: alignments[idx], - children: parseInlineContent(cell) as CustomDescendant[], - })), - } - }) - - const table: TableElement = { - type: "table", - children: [headerRow, ...dataRows], - } - - return { table, endIndex: i } -} - -/** - * Deserialize markdown string to Slate value - * This converts markdown from the backend back to Slate nodes for editing - */ -export function deserializeFromMarkdown(markdown: string): CustomDescendant[] { - if (!markdown || markdown.trim() === "") { - return [ - { - type: "paragraph", - children: [{ text: "" }], - }, - ] - } - - const nodes: CustomDescendant[] = [] - const lines = markdown.split("\n") - let i = 0 - - while (i < lines.length) { - const line = lines[i] - if (!line) { - i++ - continue - } - - // Check for code block (```) - if (line.startsWith("```")) { - const languageMatch = line.match(/^```(\w+)?/) - const language = languageMatch?.[1] || undefined - const codeLines: string[] = [] - - i++ // Skip opening ``` - while (i < lines.length) { - const codeLine = lines[i] - // Only break on closing ```, not on empty lines - if (codeLine?.startsWith("```")) break - codeLines.push(codeLine ?? "") - i++ - } - i++ // Skip closing ``` - - nodes.push({ - type: "code-block", - language, - children: [{ text: codeLines.join("\n") }], - }) - continue - } - - // Check for headings (# ## ###) - const headingMatch = line.match(/^(#{1,3})\s+(.+)$/) - if (headingMatch) { - const level = headingMatch[1]!.length as 1 | 2 | 3 - const content = headingMatch[2]! - nodes.push({ - type: "heading", - level, - children: parseInlineContent(content) as CustomText[], - }) - i++ - continue - } - - // Check for GFM table - if (isTableRow(line)) { - const result = parseTable(lines, i) - if (result) { - nodes.push(result.table) - i = result.endIndex - continue - } - } - - // Check for multi-line blockquote (>>>) - if (line.startsWith(">>> ")) { - const quoteText = line.slice(4) // Remove ">>> " - const restOfMessage = lines.slice(i + 1).join("\n") - const fullQuote = restOfMessage ? `${quoteText}\n${restOfMessage}` : quoteText - - nodes.push({ - type: "blockquote", - children: parseInlineContent(fullQuote) as CustomText[], - }) - break // Multi-line quote consumes rest of message - } - - // Check for single-line blockquote (>) - if (line.startsWith("> ")) { - const quoteLines: string[] = [] - while (i < lines.length) { - const quoteLine = lines[i] - if (!quoteLine || !quoteLine.startsWith("> ")) break - quoteLines.push(quoteLine.slice(2)) // Remove "> " - i++ - } - - const quoteText = quoteLines.join("\n") - nodes.push({ - type: "blockquote", - children: parseInlineContent(quoteText) as CustomText[], - }) - continue - } - - // Check for subtext (-#) - if (line.startsWith("-# ")) { - const subtextContent = line.slice(3) // Remove "-# " - nodes.push({ - type: "subtext", - children: parseInlineContent(subtextContent) as CustomText[], - }) - i++ - continue - } - - // Check for unordered list (- or *) - if (line.match(/^[-*] /)) { - const listContent = line.slice(2) // Remove "- " or "* " - nodes.push({ - type: "list-item", - ordered: false, - children: parseInlineContent(listContent) as CustomText[], - }) - i++ - continue - } - - // Check for ordered list (1. 2. etc) - if (line.match(/^\d+\. /)) { - const listContent = line.replace(/^\d+\. /, "") // Remove number and ". " - nodes.push({ - type: "list-item", - ordered: true, - children: parseInlineContent(listContent) as CustomText[], - }) - i++ - continue - } - - // Default: paragraph with inline content (including mentions) - nodes.push({ - type: "paragraph", - children: parseInlineContent(line) as CustomText[], - }) - i++ - } - - return nodes.length > 0 ? nodes : [{ type: "paragraph", children: [{ text: "" }] }] -} - -/** - * Create an empty Slate value - */ -export function createEmptyValue(): CustomDescendant[] { - return [ - { - type: "paragraph", - children: [{ text: "" }], - }, - ] -} - -/** - * Check if Slate value is effectively empty - */ -export function isValueEmpty(nodes: CustomDescendant[]): boolean { - if (!nodes || nodes.length === 0) return true - - const hasMeaningfulContent = (node: CustomDescendant): boolean => { - if ("text" in node) { - return node.text.trim().length > 0 - } - - const element = node as CustomElement - - // Inline void nodes carry semantic content even though Node.string() is empty. - if (element.type === "mention" || element.type === "custom-emoji") { - return true - } - - if ("children" in element && Array.isArray(element.children)) { - return element.children.some((child) => hasMeaningfulContent(child as CustomDescendant)) - } - - return Node.string(element).trim().length > 0 - } - - return !nodes.some((node) => hasMeaningfulContent(node)) -} diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-message-viewer-model.ts b/apps/web-foldkit/src/core/components/chat/slate-editor/slate-message-viewer-model.ts deleted file mode 100644 index a42ccc94e..000000000 --- a/apps/web-foldkit/src/core/components/chat/slate-editor/slate-message-viewer-model.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Element, type Path, Text } from "slate" -import { decorateCodeBlock } from "./slate-code-decorator" -import { decorateEmoji } from "./slate-emoji-decorator" -import { decorateMarkdown } from "./slate-markdown-decorations" - -/** - * What the read-only Slate viewer renders, without an editor instance: the parsed blocks and the - * decorated leaves of each text node. The Foldkit message viewer is a pure view over this. - */ - -export { isEmojiOnly } from "~/lib/emoji-utils" -export { markdownLeafClassName } from "./slate-markdown-decorations" -export { - deserializeFromMarkdown, - type CustomDescendant, - type CustomElement, - type CustomText, -} from "./slate-markdown-serializer" - -export type ViewerLeaf = Readonly & { text: string }> - -/** Same ranges as the viewer's `decorate`, split into leaves by Slate's own `Text.decorations`. */ -export function viewerLeaves(text: { text: string }, path: Path, parent: unknown): ReadonlyArray { - const ranges = [...decorateMarkdown([text, path], parent), ...decorateEmoji([text, path], parent)] - return Text.decorations(text, ranges).map(({ leaf }) => leaf as ViewerLeaf) -} - -/** Leaves of a code block's text: the Prism ranges the viewer's `decorate` gives the block. */ -export function codeBlockLeaves( - block: unknown, - blockPath: Path, - text: { text: string }, - textPath: Path, -): ReadonlyArray { - if (!Element.isElement(block)) return [text as ViewerLeaf] - const ranges = decorateCodeBlock([block, blockPath]).map((range) => ({ - ...range, - anchor: { path: textPath, offset: range.anchor.offset }, - focus: { path: textPath, offset: range.focus.offset }, - })) - return Text.decorations(text as Text, ranges).map(({ leaf }) => leaf as ViewerLeaf) -} diff --git a/apps/web-foldkit/src/core/components/chat/slate-editor/types.ts b/apps/web-foldkit/src/core/components/chat/slate-editor/types.ts deleted file mode 100644 index 1f4b7c13d..000000000 --- a/apps/web-foldkit/src/core/components/chat/slate-editor/types.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { BaseEditor, BaseElement, BaseText, Descendant } from "slate" - -// Custom Element Types -export interface CodeBlockElement extends BaseElement { - type: "code-block" - language?: string - children: Descendant[] -} - -export interface MentionElement extends BaseElement { - type: "mention" - userId: string - displayName: string - children: [{ text: "" }] -} - -export interface CustomEmojiElement extends BaseElement { - type: "custom-emoji" - name: string - imageUrl: string - children: [{ text: "" }] -} - -export interface ParagraphElement extends BaseElement { - type: "paragraph" - children: Descendant[] -} - -export interface BlockquoteElement extends BaseElement { - type: "blockquote" - children: Descendant[] -} - -export interface SubtextElement extends BaseElement { - type: "subtext" - children: Descendant[] -} - -export interface ListItemElement extends BaseElement { - type: "list-item" - children: Descendant[] -} - -export interface OrderedListElement extends BaseElement { - type: "ordered-list" - children: Descendant[] -} - -export interface UnorderedListElement extends BaseElement { - type: "unordered-list" - children: Descendant[] -} - -export interface HeadingElement extends BaseElement { - type: "heading" - level: number - children: Descendant[] -} - -export interface TableElement extends BaseElement { - type: "table" - children: TableRowElement[] -} - -export interface TableRowElement extends BaseElement { - type: "table-row" - children: TableCellElement[] -} - -export interface TableCellElement extends BaseElement { - type: "table-cell" - header?: boolean - align?: "left" | "center" | "right" - children: Descendant[] -} - -export type CustomElement = - | CodeBlockElement - | MentionElement - | CustomEmojiElement - | ParagraphElement - | BlockquoteElement - | SubtextElement - | ListItemElement - | OrderedListElement - | UnorderedListElement - | HeadingElement - | TableElement - | TableRowElement - | TableCellElement - -// Custom Text/Leaf Types (for markdown decorations) -export type MarkdownLeaf = BaseText & { - type?: "bold" | "italic" | "code" | "strikethrough" | "link" - isMarker?: boolean - url?: string - linkText?: string -} - -// Extend Slate's types -declare module "slate" { - interface CustomTypes { - Editor: BaseEditor - Element: CustomElement - Text: MarkdownLeaf - } -} - -// Type guards -export function isCodeBlockElement(element: BaseElement): element is CodeBlockElement { - return (element as CustomElement).type === "code-block" -} - -export function isMentionElement(element: BaseElement): element is MentionElement { - return (element as CustomElement).type === "mention" -} - -export function isCustomEmojiElement(element: BaseElement): element is CustomEmojiElement { - return (element as CustomElement).type === "custom-emoji" -} - -export function isParagraphElement(element: BaseElement): element is ParagraphElement { - return (element as CustomElement).type === "paragraph" -} - -export function isTableElement(element: BaseElement): element is TableElement { - return (element as CustomElement).type === "table" -} - -export function isTableRowElement(element: BaseElement): element is TableRowElement { - return (element as CustomElement).type === "table-row" -} - -export function isTableCellElement(element: BaseElement): element is TableCellElement { - return (element as CustomElement).type === "table-cell" -} diff --git a/apps/web-foldkit/src/core/components/embeds/embed-markdown.parser.ts b/apps/web-foldkit/src/core/components/embeds/embed-markdown.parser.ts index f0f1d03db..7bc22c781 100644 --- a/apps/web-foldkit/src/core/components/embeds/embed-markdown.parser.ts +++ b/apps/web-foldkit/src/core/components/embeds/embed-markdown.parser.ts @@ -1,4 +1,4 @@ -/** The inline markdown parser of `EmbedMarkdown`, framework-free so the Foldkit app shares it. */ +/** The inline markdown parser of `EmbedMarkdown`. */ export interface ParsedSegment { type: "text" | "bold" | "italic" | "strikethrough" | "code" | "link" | "url" | "colored" | "badge" diff --git a/apps/web-foldkit/src/core/components/embeds/embed-markdown.styles.ts b/apps/web-foldkit/src/core/components/embeds/embed-markdown.styles.ts index de8113d97..4b7529bca 100644 --- a/apps/web-foldkit/src/core/components/embeds/embed-markdown.styles.ts +++ b/apps/web-foldkit/src/core/components/embeds/embed-markdown.styles.ts @@ -1,5 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ - /** Map of color names to Tailwind classes */ export const colorMap: Record = { red: "text-red-500", diff --git a/apps/web-foldkit/src/core/components/embeds/embed.styles.ts b/apps/web-foldkit/src/core/components/embeds/embed.styles.ts index 3e95a64c8..5d2f11693 100644 --- a/apps/web-foldkit/src/core/components/embeds/embed.styles.ts +++ b/apps/web-foldkit/src/core/components/embeds/embed.styles.ts @@ -1,7 +1,5 @@ import { tv, type VariantProps } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ - // ============================================================================ // Shared Embed Styles // ============================================================================ diff --git a/apps/web-foldkit/src/core/components/link-preview.utils.ts b/apps/web-foldkit/src/core/components/link-preview.utils.ts index 5890e7f70..b3c4d7380 100644 --- a/apps/web-foldkit/src/core/components/link-preview.utils.ts +++ b/apps/web-foldkit/src/core/components/link-preview.utils.ts @@ -142,7 +142,7 @@ export function isGiphyUrl(url: string): boolean { hostname === "i.giphy.com" || (hostname.startsWith("media") && hostname.endsWith(".giphy.com")) || ((hostname === "giphy.com" || hostname === "www.giphy.com") && - (/^\/media\//.test(parsed.pathname) || /^\/gifs\//.test(parsed.pathname))) + (parsed.pathname.startsWith("/media/") || parsed.pathname.startsWith("/gifs/"))) ) } catch { return false diff --git a/apps/web-foldkit/src/core/components/ui/badge.styles.ts b/apps/web-foldkit/src/core/components/ui/badge.styles.ts index 4c6f38bbd..1c6a84dec 100644 --- a/apps/web-foldkit/src/core/components/ui/badge.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/badge.styles.ts @@ -1,6 +1,5 @@ import { tv } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const badgeStyles = tv({ base: [ "inline-flex items-center font-medium forced-colors:outline", diff --git a/apps/web-foldkit/src/core/components/ui/button.styles.ts b/apps/web-foldkit/src/core/components/ui/button.styles.ts index 8dab0076a..f287d0d89 100644 --- a/apps/web-foldkit/src/core/components/ui/button.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/button.styles.ts @@ -1,15 +1,14 @@ import { tv } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const buttonStyles = tv({ base: [ "[--btn-border:var(--color-fg)]/15 [--btn-icon-active:var(--btn-fg)] [--btn-outline:var(--btn-bg)] [--btn-radius:calc(var(--radius-lg)-1px)] [--btn-ring:var(--btn-bg)]/20", - "bg-(--btn-bg) text-(--btn-fg) outline-(--btn-outline) ring-(--btn-ring) hover:bg-(--btn-overlay)", + "bg-(--btn-bg) text-(--btn-fg) outline-(--btn-outline) ring-(--btn-ring) not-disabled:not-data-pending:hover:bg-(--btn-overlay)", "relative isolate inline-flex items-center justify-center border border-(--btn-border) font-medium hover:no-underline", "focus:outline-0 focus-visible:outline focus-visible:outline-offset-2 focus-visible:ring-2 focus-visible:ring-offset-3 focus-visible:ring-offset-bg", - "*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) focus-visible:*:data-[slot=icon]:text-(--btn-icon-active)/80 hover:*:data-[slot=icon]:text-(--btn-icon-active)/90 forced-colors:[--btn-icon:ButtonText] forced-colors:hover:[--btn-icon:ButtonText]", + "*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) focus-visible:*:data-[slot=icon]:text-(--btn-icon-active)/80 not-disabled:not-data-pending:hover:*:data-[slot=icon]:text-(--btn-icon-active)/90 forced-colors:[--btn-icon:ButtonText] forced-colors:hover:[--btn-icon:ButtonText]", "*:data-[slot=loader]:-mx-0.5 *:data-[slot=loader]:shrink-0 *:data-[slot=loader]:self-center *:data-[slot=loader]:text-(--btn-icon)", - "pending:opacity-50 disabled:opacity-50 disabled:forced-colors:text-[GrayText]", + "data-pending:opacity-50 data-disabled:opacity-50 data-disabled:forced-colors:text-[GrayText]", "*:data-[slot=color-swatch]:-mx-0.5 *:data-[slot=color-swatch]:shrink-0 *:data-[slot=color-swatch]:self-center *:data-[slot=color-swatch]:[--color-swatch-size:--spacing(5)]", ], variants: { diff --git a/apps/web-foldkit/src/core/components/ui/calendar.styles.ts b/apps/web-foldkit/src/core/components/ui/calendar.styles.ts index 48cdfff3a..cb2ea22ea 100644 --- a/apps/web-foldkit/src/core/components/ui/calendar.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/calendar.styles.ts @@ -1,7 +1,5 @@ import { twMerge } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ - const todayDot = "after:pointer-events-none after:absolute after:start-1/2 after:bottom-1 after:z-10 after:size-0.75 after:-translate-x-1/2 after:rounded-full after:bg-primary" @@ -13,9 +11,9 @@ export const calendarCellClassName = ( twMerge( "relative flex size-11 cursor-default items-center justify-center rounded-lg text-fg tabular-nums outline-hidden hover:bg-secondary-fg/15 sm:size-9 sm:text-sm/6 forced-colors:text-[ButtonText] forced-colors:outline-0", state.isSelected && - "bg-primary pressed:bg-primary text-primary-fg hover:bg-primary/90 data-invalid:bg-danger data-invalid:text-danger-fg forced-colors:bg-[Highlight] forced-colors:text-[Highlight] forced-colors:data-invalid:bg-[Mark]", + "bg-primary data-pressed:bg-primary text-primary-fg hover:bg-primary/90 data-invalid:bg-danger data-invalid:text-danger-fg forced-colors:bg-[Highlight] forced-colors:text-[Highlight] forced-colors:data-invalid:bg-[Mark]", state.isDisabled && "text-muted-fg forced-colors:text-[GrayText]", - state.isToday && `${todayDot} selected:after:bg-primary-fg focus-visible:after:bg-primary-fg`, + state.isToday && `${todayDot} data-selected:after:bg-primary-fg focus-visible:after:bg-primary-fg`, className, ) @@ -31,46 +29,3 @@ export const calendarYearSelectTriggerClassName = "text-sm/5 sm:px-2.5 sm:py-1.5 export const calendarHeaderCellClassName = "pb-2 text-center font-semibold text-muted-fg text-sm/6 sm:px-0 sm:py-0.5 lg:text-xs" - -export const rangeCalendarGridsClassName = - "flex snap-x items-start justify-stretch gap-6 overflow-auto sm:gap-10" - -export const rangeCalendarGridClassName = "[&_td]:border-collapse [&_td]:px-0 [&_td]:py-0.5" - -/** RangeCalendar's CalendarCell className. */ -export const rangeCalendarCellClassName = (isToday: boolean) => - twMerge([ - "shrink-0 [--cell-fg:var(--color-primary-subtle-fg)] [--cell:var(--color-primary-subtle)]", - "group/calendar-cell relative size-11 cursor-default outside-month:text-muted-fg leading-[2.286rem] outline-hidden selection-start:rounded-s-lg selection-end:rounded-e-lg sm:size-9 sm:text-sm", - "selected:bg-(--cell) selected:text-(--cell-fg)", - "selected:after:bg-primary-fg focus-visible:after:bg-primary-fg", - "invalid:selected:bg-danger-subtle", - "[td:first-child_&]:rounded-s-lg [td:last-child_&]:rounded-e-lg", - "forced-colors:selected:bg-[Highlight] forced-colors:selected:text-[HighlightText] forced-colors:invalid:selected:bg-[Mark]", - isToday && `${todayDot} selected:after:bg-primary-fg`, - ]) - -/** The `` inside a RangeCalendar cell. */ -export const rangeCalendarCellInnerClassName = (state: { - readonly isSelected: boolean - readonly isSelectionEdge: boolean - readonly isDisabled: boolean -}) => - twMerge( - "flex size-full items-center justify-center rounded-lg tabular-nums forced-color-adjust-none", - state.isSelected && state.isSelectionEdge - ? "bg-primary text-primary-fg group-invalid/calendar-cell:bg-danger group-invalid/calendar-cell:text-danger-fg forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] forced-colors:group-invalid/calendar-cell:bg-[Mark]" - : state.isSelected - ? [ - // hover - "group-hover/calendar-cell:bg-primary/15", - // pressed - "group-pressed/calendar-cell:bg-(--cell)", - // invalid - "group-invalid/calendar-cell:text-danger-subtle-fg group-invalid/calendar-cell:group-hover/calendar-cell:bg-danger/15 group-invalid/calendar-cell:group-pressed/calendar-cell:bg-danger/30", - // forced-colors - "forced-colors:text-[HighlightText] forced-colors:group-pressed/calendar-cell:bg-[Highlight] forced-colors:group-hover/calendar-cell:bg-[Highlight] forced-colors:group-invalid/calendar-cell:group-pressed/calendar-cell:bg-[Mark] forced-colors:group-invalid:group-hover/calendar-cell:bg-[Mark]", - ] - : "group-hover/calendar-cell:bg-secondary-fg/15 group-pressed/calendar-cell:bg-secondary-fg/20 forced-colors:group-pressed/calendar-cell:bg-[Highlight]", - state.isDisabled && "opacity-50 forced-colors:text-[GrayText]", - ) diff --git a/apps/web-foldkit/src/core/components/ui/card.styles.ts b/apps/web-foldkit/src/core/components/ui/card.styles.ts index 0c551b38b..0c1b28b32 100644 --- a/apps/web-foldkit/src/core/components/ui/card.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/card.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const cardStyles = { card: "overflow-hidden rounded-xl border shadow-sm", cardDefault: "border-border bg-bg", diff --git a/apps/web-foldkit/src/core/components/ui/checkbox.styles.ts b/apps/web-foldkit/src/core/components/ui/checkbox.styles.ts index b86820bd8..2a9169231 100644 --- a/apps/web-foldkit/src/core/components/ui/checkbox.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/checkbox.styles.ts @@ -1,11 +1,7 @@ import type { ClassNameValue } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const checkboxGroupStyles = - "space-y-3 has-[[slot=description]]:space-y-6 has-[[slot=description]]:**:data-[slot=label]:font-medium **:[[slot=description]]:block" - export const checkboxStyles = - "group block [--indicator-mt:--spacing(0.75)] disabled:opacity-50 sm:[--indicator-mt:--spacing(1)]" + "group block [--indicator-mt:--spacing(0.75)] data-disabled:opacity-50 sm:[--indicator-mt:--spacing(1)]" /** The grid inside the checkbox label: `twMerge(...checkboxLayoutStyles)`. */ export const checkboxLayoutStyles = [ @@ -19,29 +15,25 @@ export const checkboxLayoutStyles = [ export interface CheckboxIndicatorState { readonly isSelected: boolean readonly isIndeterminate: boolean - readonly isFocusVisible: boolean readonly isInvalid: boolean } -/** The box: `twMerge(checkboxIndicatorStyles(state))`. */ +/** The box: `twMerge(checkboxIndicatorStyles(state))`. Hover and focus rings follow the label (`group`). */ export const checkboxIndicatorStyles = ({ isSelected, isIndeterminate, - isFocusVisible, isInvalid, }: CheckboxIndicatorState): ClassNameValue[] => [ "relative inset-ring inset-ring-input isolate flex shrink-0 items-center justify-center rounded text-bg transition group-hover:inset-ring-muted-fg/30", "sm:size-4 sm:*:data-[slot=check-indicator]:size-3.5", "size-4.5 *:data-[slot=check-indicator]:size-4", - "in-disabled:bg-muted", + "in-data-disabled:bg-muted", (isSelected || isIndeterminate) && [ "inset-ring-(--checkbox-ring,var(--color-ring)) bg-(--checkbox-bg,var(--color-primary)) text-(--checkbox-fg,var(--color-primary-fg))", - "group-invalid:inset-ring/70 group-invalid:bg-danger group-invalid:text-danger-fg dark:group-invalid:inset-ring-danger-subtle-fg/70", - ], - isFocusVisible && [ - "inset-ring-(--checkbox-ring,var(--color-ring)) ring-(--checkbox-ring,var(--color-ring))/20 ring-3", - "group-invalid:inset-ring-danger-subtle-fg/70 group-invalid:text-danger-fg group-invalid:ring-danger-subtle-fg/20", + "group-data-invalid:inset-ring/70 group-data-invalid:bg-danger group-data-invalid:text-danger-fg dark:group-data-invalid:inset-ring-danger-subtle-fg/70", ], + "group-has-focus-visible:inset-ring-(--checkbox-ring,var(--color-ring)) group-has-focus-visible:ring-(--checkbox-ring,var(--color-ring))/20 group-has-focus-visible:ring-3", + "group-has-focus-visible:group-data-invalid:inset-ring-danger-subtle-fg/70 group-has-focus-visible:group-data-invalid:text-danger-fg group-has-focus-visible:group-data-invalid:ring-danger-subtle-fg/20", isInvalid && "inset-ring-danger-subtle-fg/70 bg-danger-subtle/5 text-danger-fg ring-danger-subtle-fg/20 group-hover:inset-ring-danger-subtle-fg/70", ] diff --git a/apps/web-foldkit/src/core/components/ui/choice-box.styles.ts b/apps/web-foldkit/src/core/components/ui/choice-box.styles.ts index 6bfd4b788..5323aa647 100644 --- a/apps/web-foldkit/src/core/components/ui/choice-box.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/choice-box.styles.ts @@ -1,6 +1,5 @@ import { tv } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const choiceBoxStyles = tv({ base: "grid [--gutter:--spacing(4)]", variants: { @@ -55,10 +54,10 @@ export const choiceBoxItemStyles = tv({ false: "cursor-default", }, isHovered: { - true: "not-data-readonly:not-data-focus-visible:not-selected:inset-ring-muted-fg/30", + true: "not-data-readonly:not-data-focus-visible:not-data-selected:inset-ring-muted-fg/30", }, isFocused: { - true: "inset-ring-(--choice-box-active-ring) ring-(--choice-box-ring) ring-3 invalid:ring-danger-subtle-fg/20", + true: "inset-ring-(--choice-box-active-ring) ring-(--choice-box-ring) ring-3 data-invalid:ring-danger-subtle-fg/20", }, isInvalid: { true: "ring-3 ring-danger-subtle-fg/20" }, isOneColumn: { @@ -79,7 +78,7 @@ export const choiceBoxItemStyles = tv({ }) export const choiceBoxLabelStyles = [ - "select-none text-base/6 text-fg group-disabled:opacity-50 sm:text-sm/6", + "select-none text-base/6 text-fg group-data-disabled:opacity-50 sm:text-sm/6", "col-start-1 row-start-1", "group-has-data-[slot=icon]:col-start-2", "group-has-data-[slot=avatar]:col-start-2", @@ -90,7 +89,7 @@ export const choiceBoxDescriptionStyles = [ "group-has-data-[slot=icon]:col-start-2", "group-has-data-[slot=avatar]:col-start-2", "text-base/6 text-muted-fg sm:text-sm/6", - "group-disabled:opacity-50", + "group-data-disabled:opacity-50", ] /** The selection checkbox in multiple selection mode. */ diff --git a/apps/web-foldkit/src/core/components/ui/combo-box.styles.ts b/apps/web-foldkit/src/core/components/ui/combo-box.styles.ts index 9e8475764..3466109ad 100644 --- a/apps/web-foldkit/src/core/components/ui/combo-box.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/combo-box.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const comboBoxPopoverBase = "min-w-(--trigger-width) scroll-py-1 overflow-y-auto overscroll-contain" export const comboBoxListBoxBase = diff --git a/apps/web-foldkit/src/core/components/ui/command-menu-form.styles.ts b/apps/web-foldkit/src/core/components/ui/command-menu-form.styles.ts index bfda5ef2b..415d00736 100644 --- a/apps/web-foldkit/src/core/components/ui/command-menu-form.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/command-menu-form.styles.ts @@ -1,4 +1,4 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. Each base is merged with `className`. */ +/** Each base is merged with `className`. */ export const commandMenuFormContainerBase = "flex h-full flex-col overflow-hidden" export const commandMenuFormHeaderBase = "flex items-center gap-2 border-b px-3 py-2.5 sm:px-2.5 sm:py-2" export const commandMenuFormBackClassName = diff --git a/apps/web-foldkit/src/core/components/ui/command-menu.styles.ts b/apps/web-foldkit/src/core/components/ui/command-menu.styles.ts index 521b4655c..f9fe3b099 100644 --- a/apps/web-foldkit/src/core/components/ui/command-menu.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/command-menu.styles.ts @@ -1,6 +1,5 @@ import { twJoin } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const commandMenuSizes = { xs: "sm:max-w-xs", sm: "sm:max-w-sm", @@ -15,10 +14,9 @@ export type CommandMenuSize = keyof typeof commandMenuSizes export const commandMenuOverlayClassName = (isBlurred: boolean) => twJoin( - "fixed inset-0 z-50 h-(--visual-viewport-height,100vh) w-screen overflow-hidden bg-overlay-backdrop", + "fixed inset-0 z-50 h-dvh w-screen overflow-hidden bg-overlay-backdrop", "grid grid-rows-[1fr_auto] justify-items-center text-center sm:grid-rows-[1fr_auto_3fr]", - "entering:fade-in entering:animate-in entering:duration-300 entering:ease-out", - "exiting:fade-out exiting:animate-out exiting:ease-in", + "transition-opacity duration-200 ease-out starting:opacity-0", isBlurred && "backdrop-blur-sm backdrop-filter", ) @@ -26,17 +24,17 @@ export const commandMenuOverlayClassName = (isBlurred: boolean) => export const commandMenuModalBase = (size: CommandMenuSize) => [ "row-start-2 bg-overlay text-left text-overlay-fg shadow-lg outline-none ring ring-muted-fg/15 md:row-start-1 dark:ring-border", - "max-h-[calc(var(--visual-viewport-height)*0.8)] w-full sm:fixed sm:top-[10%] sm:left-1/2 sm:-translate-x-1/2", + "max-h-[80dvh] w-full sm:fixed sm:top-[10%] sm:left-1/2 sm:-translate-x-1/2", "rounded-t-2xl md:rounded-xl", commandMenuSizes[size], - "entering:slide-in-from-bottom sm:entering:zoom-in-95 sm:entering:slide-in-from-bottom-0 entering:animate-in entering:duration-300 entering:ease-out", - "exiting:slide-out-to-bottom sm:exiting:zoom-out-95 sm:exiting:slide-out-to-bottom-0 exiting:animate-out exiting:ease-in", + // Fades and rises (scales on wider screens) in from `@starting-style`, in 200ms. + "transition-[opacity,translate,scale] duration-200 ease-out", + "starting:translate-y-4 starting:opacity-0 sm:starting:translate-y-0 sm:starting:scale-95", ] as const export const commandMenuDialogClassName = "flex max-h-[inherit] flex-col overflow-hidden outline-hidden" export const commandMenuContentsClassName = "contents" export const commandMenuSearchBase = "flex w-full items-center px-2.5 py-1" -export const commandMenuLoaderClassName = "size-4.5" export const commandMenuSearchIconClassName = "size-5 shrink-0 text-muted-fg" export const commandMenuInputClassName = "w-full min-w-0 bg-transparent px-2.5 py-2 text-base text-fg placeholder-muted-fg outline-hidden focus:outline-hidden sm:px-2 sm:py-1.5 sm:text-sm [&::-ms-reveal]:hidden [&::-webkit-search-cancel-button]:hidden" @@ -51,10 +49,5 @@ export const commandMenuSectionHeaderClassName = export const commandMenuItemBase = "items-center gap-y-0.5" export const commandMenuDescriptionBase = "col-start-3 row-start-1 ml-auto" export const commandMenuEmptyClassName = "col-span-full p-4 text-center text-muted-fg text-sm" -export const commandMenuSeparatorBase = "-mx-2" -export const commandMenuFooterBase = [ - "col-span-full flex-none border-t px-2 py-1.5 text-muted-fg text-sm", - "*:[kbd]:inset-ring *:[kbd]:inset-ring-fg/10 *:[kbd]:mx-1 *:[kbd]:inline-grid *:[kbd]:h-4 *:[kbd]:min-w-4 *:[kbd]:place-content-center *:[kbd]:rounded-xs *:[kbd]:bg-secondary", -] as const export const commandMenuShortcutBase = "gap-0.5 font-sans text-[10.5px] uppercase *:inset-ring *:inset-ring-muted-fg/20 *:grid *:size-5.5 *:place-content-center *:rounded-xs *:bg-bg" diff --git a/apps/web-foldkit/src/core/components/ui/context-menu.styles.ts b/apps/web-foldkit/src/core/components/ui/context-menu.styles.ts deleted file mode 100644 index 60bba930e..000000000 --- a/apps/web-foldkit/src/core/components/ui/context-menu.styles.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. Merged with `className` by `twMerge`. */ -export const contextMenuTriggerBase = "cursor-default focus:outline-hidden" diff --git a/apps/web-foldkit/src/core/components/ui/date-field.styles.ts b/apps/web-foldkit/src/core/components/ui/date-field.styles.ts index 862a51f96..be0df86a7 100644 --- a/apps/web-foldkit/src/core/components/ui/date-field.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/date-field.styles.ts @@ -1,12 +1,11 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const dateInputStyles = [ "relative block appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]", "text-base/6 text-fg placeholder:text-muted-fg sm:text-sm/6", "border border-input enabled:hover:border-muted-fg/30", "focus-within:border-ring/70 focus-within:outline-hidden focus-within:ring-3 focus-within:ring-ring/20 focus-within:hover:border-ring/80", - "group-open:border-ring/70 group-open:outline-hidden group-open:ring-3 group-open:ring-ring/20 group-open:enabled:hover:border-ring/80", - "invalid:border-danger-subtle-fg/70 focus-within:invalid:border-danger-subtle-fg/70 focus-within:invalid:ring-danger-subtle-fg/20 focus-within:invalid:hover:border-danger-subtle-fg/80 invalid:enabled:hover:border-danger-subtle-fg/80", - "in-disabled:bg-muted disabled:bg-muted forced-colors:in-disabled:text-[GrayText] forced-colors:disabled:text-[GrayText]", + "group-data-open:border-ring/70 group-data-open:outline-hidden group-data-open:ring-3 group-data-open:ring-ring/20 group-data-open:enabled:hover:border-ring/80", + "data-invalid:border-danger-subtle-fg/70 focus-within:data-invalid:border-danger-subtle-fg/70 focus-within:data-invalid:ring-danger-subtle-fg/20 focus-within:data-invalid:hover:border-danger-subtle-fg/80 data-invalid:enabled:hover:border-danger-subtle-fg/80", + "in-data-disabled:bg-muted data-disabled:bg-muted forced-colors:in-data-disabled:text-[GrayText] forced-colors:data-disabled:text-[GrayText]", "dark:scheme-dark", ] @@ -15,10 +14,10 @@ export const dateInputControlStyles = "relative block" /** Each DateSegment: `twJoin(...dateSegmentStyles)`. */ export const dateSegmentStyles = [ - "inline shrink-0 rounded px-1 type-literal:px-0 py-0.5 text-fg tracking-wider caret-transparent outline-0 data-placeholder:not-data-focused:text-muted-fg sm:text-sm", + "inline shrink-0 rounded px-1 data-[type=literal]:px-0 py-0.5 text-fg tracking-wider caret-transparent outline-0 data-placeholder:not-data-focused:text-muted-fg sm:text-sm", "focus:bg-primary-subtle focus:text-primary-subtle-fg focus:data-invalid:bg-danger-subtle focus:data-invalid:text-danger-subtle-fg forced-colors:focus:bg-[Highlight] forced-colors:focus:text-[HighlightText]", "forced-color-adjust-none forced-colors:text-[ButtonText]", - "in-disabled:bg-muted disabled:bg-muted forced-colors:disabled:text-[GrayText]", + "in-data-disabled:bg-muted data-disabled:bg-muted forced-colors:data-disabled:text-[GrayText]", ] /** DateField and TimeField roots add this to `fieldStyles`. */ diff --git a/apps/web-foldkit/src/core/components/ui/date-picker.styles.ts b/apps/web-foldkit/src/core/components/ui/date-picker.styles.ts index 0d7859e3c..f51f4bf48 100644 --- a/apps/web-foldkit/src/core/components/ui/date-picker.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/date-picker.styles.ts @@ -1,7 +1,5 @@ import { twJoin } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ - /** DatePickerOverlay's PopoverContent className. */ export const datePickerOverlayClassName = (months: number | undefined) => twJoin( @@ -12,7 +10,7 @@ export const datePickerOverlayClassName = (months: number | undefined) => /** DatePickerTrigger's calendar button. */ export const datePickerTriggerClassName = twJoin( "touch-target grid place-content-center outline-hidden", - "pressed:text-fg text-muted-fg hover:text-fg focus-visible:text-fg", + "data-pressed:text-fg text-muted-fg hover:text-fg focus-visible:text-fg", "px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6", "*:data-[slot=icon]:size-4.5 sm:*:data-[slot=icon]:size-4", ) diff --git a/apps/web-foldkit/src/core/components/ui/dialog.styles.ts b/apps/web-foldkit/src/core/components/ui/dialog.styles.ts index b64d36eec..5e4db28e5 100644 --- a/apps/web-foldkit/src/core/components/ui/dialog.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dialog.styles.ts @@ -1,18 +1,16 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. Each base is merged with `className`. */ +/** Each base is merged with `className`. */ export const dialogBase = "peer/dialog group/dialog relative flex max-h-[inherit] flex-col overflow-hidden outline-hidden [--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]" -export const dialogTriggerBase = "cursor-pointer" - export const dialogHeaderBase = "relative space-y-1 p-(--gutter) pb-[calc(var(--gutter)---spacing(3))]" export const dialogTitleBase = "text-balance font-semibold text-fg text-lg/6 sm:text-base/6" export const dialogDescriptionBase = - "text-pretty text-base/6 text-muted-fg group-disabled:opacity-50 sm:text-sm/6" + "text-pretty text-base/6 text-muted-fg group-data-disabled:opacity-50 sm:text-sm/6" export const dialogBodyBase = [ - "isolate flex max-h-[calc(var(--visual-viewport-height)-var(--visual-viewport-vertical-padding)-var(--dialog-header-height,0px)-var(--dialog-footer-height,0px))] flex-1 flex-col overflow-auto px-(--gutter) py-1", + "isolate flex min-h-0 flex-1 flex-col overflow-auto px-(--gutter) py-1", "**:data-[slot=dialog-footer]:px-0 **:data-[slot=dialog-footer]:pt-0", ] as const diff --git a/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts b/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts index 8914bab08..b9a92e926 100644 --- a/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/dropdown.styles.ts @@ -1,7 +1,6 @@ import { twJoin } from "tailwind-merge" import { tv } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const dropdownSectionStyles = tv({ slots: { section: "col-span-full grid grid-cols-[auto_1fr]", @@ -25,21 +24,21 @@ export const dropdownItemStyles = tv({ "has-[[slot=description]]:*:data-[slot=icon]:h-lh has-[[slot=description]]:[&_[data-slot='icon']:not([class*='w-'])]:w-5 sm:has-[[slot=description]]:[&_[data-slot='icon']:not([class*='w-'])]:w-4", "[&>[slot=label]+[data-slot=icon]]:absolute [&>[slot=label]+[data-slot=icon]]:right-1", // force color adjust - "forced-color-adjust-none forced-colors:focus:bg-[Highlight] forced-colors:focus:text-[HighlightText] forced-colors:focus:*:data-[slot=icon]:text-[HighlightText]", + "forced-color-adjust-none forced-colors:data-focused:bg-[Highlight] forced-colors:data-focused:text-[HighlightText] forced-colors:data-focused:*:data-[slot=icon]:text-[HighlightText]", ], variants: { intent: { danger: [ - "text-danger-subtle-fg focus:text-danger-subtle-fg [&_[data-slot='icon']:not([class*='text-'])]:text-danger-subtle-fg/70", - "*:[[slot=description]]:text-danger-subtle-fg/80 focus:*:[[slot=description]]:text-danger-subtle-fg focus:*:[[slot=label]]:text-danger-subtle-fg", - "focus:bg-danger-subtle focus:text-danger-subtle-fg forced-colors:focus:text-[Mark] focus:[&_[data-slot='icon']:not([class*='text-'])]:text-danger-subtle-fg", - "*:data-[slot=keyboard]:text-danger-subtle-fg/70 focus:*:data-[slot=keyboard]:text-danger-subtle-fg", + "text-danger-subtle-fg data-focused:text-danger-subtle-fg [&_[data-slot='icon']:not([class*='text-'])]:text-danger-subtle-fg/70", + "*:[[slot=description]]:text-danger-subtle-fg/80 data-focused:*:[[slot=description]]:text-danger-subtle-fg data-focused:*:[[slot=label]]:text-danger-subtle-fg", + "data-focused:bg-danger-subtle data-focused:text-danger-subtle-fg forced-colors:data-focused:text-[Mark] data-focused:[&_[data-slot='icon']:not([class*='text-'])]:text-danger-subtle-fg", + "*:data-[slot=keyboard]:text-danger-subtle-fg/70 data-focused:*:data-[slot=keyboard]:text-danger-subtle-fg", ], warning: [ - "text-warning-subtle-fg focus:text-warning-subtle-fg [&_[data-slot='icon']:not([class*='text-'])]:text-warning-subtle-fg/70", - "*:[[slot=description]]:text-warning-subtle-fg/80 focus:*:[[slot=description]]:text-warning-subtle-fg focus:*:[[slot=label]]:text-warning-subtle-fg", - "focus:bg-warning-subtle focus:text-warning-subtle-fg focus:[&_[data-slot='icon']:not([class*='text-'])]:text-warning-subtle-fg", - "*:data-[slot=keyboard]:text-warning-subtle-fg/70 focus:*:data-[slot=keyboard]:text-warning-subtle-fg", + "text-warning-subtle-fg data-focused:text-warning-subtle-fg [&_[data-slot='icon']:not([class*='text-'])]:text-warning-subtle-fg/70", + "*:[[slot=description]]:text-warning-subtle-fg/80 data-focused:*:[[slot=description]]:text-warning-subtle-fg data-focused:*:[[slot=label]]:text-warning-subtle-fg", + "data-focused:bg-warning-subtle data-focused:text-warning-subtle-fg data-focused:[&_[data-slot='icon']:not([class*='text-'])]:text-warning-subtle-fg", + "*:data-[slot=keyboard]:text-warning-subtle-fg/70 data-focused:*:data-[slot=keyboard]:text-warning-subtle-fg", ], }, isDisabled: { @@ -80,4 +79,4 @@ export const dropdownLabelBase = "col-start-2" export const dropdownDescriptionBase = "col-start-2 font-normal text-muted-fg text-sm" export const dropdownSeparatorBase = "col-span-full -mx-1 h-px bg-fg/10" export const dropdownKeyboardBase = - "absolute right-2 pl-2 group-hover:text-primary-fg group-focus:text-primary-fg" + "absolute right-2 pl-2 group-hover:text-primary-fg group-data-focused:text-primary-fg" diff --git a/apps/web-foldkit/src/core/components/ui/empty-state.styles.ts b/apps/web-foldkit/src/core/components/ui/empty-state.styles.ts index 06f17aeaf..d4001c0ed 100644 --- a/apps/web-foldkit/src/core/components/ui/empty-state.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/empty-state.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const emptyStateStyles = { root: "flex flex-col items-center justify-center py-16 text-center", iconWrapper: "mb-4 flex size-12 items-center justify-center rounded-xl bg-secondary", diff --git a/apps/web-foldkit/src/core/components/ui/field-validation-state.styles.ts b/apps/web-foldkit/src/core/components/ui/field-validation-state.styles.ts deleted file mode 100644 index 8ed336c49..000000000 --- a/apps/web-foldkit/src/core/components/ui/field-validation-state.styles.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { tv } from "tailwind-variants" - -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const validationStateStyles = tv({ - base: "absolute right-3 top-1/2 -translate-y-1/2 flex items-center justify-center", - variants: { - state: { - idle: "hidden", - validating: "text-muted-fg animate-spin", - valid: "text-success-fg", - invalid: "text-danger-subtle-fg", - }, - }, - defaultVariants: { - state: "idle", - }, -}) - -/** Size of each state icon. */ -export const validationStateIconStyles = "size-4" diff --git a/apps/web-foldkit/src/core/components/ui/field.styles.ts b/apps/web-foldkit/src/core/components/ui/field.styles.ts index 11cbf62d3..adf8faf3b 100644 --- a/apps/web-foldkit/src/core/components/ui/field.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/field.styles.ts @@ -1,18 +1,17 @@ import { tv } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const labelStyles = tv({ - base: "select-none text-base/6 text-fg in-disabled:opacity-50 group-disabled:opacity-50 sm:text-sm/6", + base: "select-none text-base/6 text-fg in-data-disabled:opacity-50 group-data-disabled:opacity-50 sm:text-sm/6", }) export const descriptionStyles = tv({ - base: "block text-muted-fg text-sm/6 in-disabled:opacity-50 group-disabled:opacity-50", + base: "block text-muted-fg text-sm/6 in-data-disabled:opacity-50 group-data-disabled:opacity-50", }) export const fieldErrorStyles = tv({ base: [ "block text-danger-subtle-fg text-sm/6", - "in-disabled:opacity-50 group-disabled:opacity-50 forced-colors:text-[Mark]", + "in-data-disabled:opacity-50 group-data-disabled:opacity-50 forced-colors:text-[Mark]", "animate-[field-error-enter_0.2s_ease-out]", ], }) @@ -29,13 +28,6 @@ export const fieldStyles = tv({ ], }) -/** FieldErrors: `twMerge(fieldErrorStyles(), list, className)`, one `item` per error with a `bullet`. */ -export const fieldErrorsStyles = { - list: "m-0 list-none space-y-1 p-0", - item: "flex items-start gap-1.5", - bullet: "mt-1.5 size-1 shrink-0 rounded-full bg-danger-subtle-fg", -} as const - export const fieldsetStyles = "*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6" export const legendStyles = "font-semibold text-base/6 data-disabled:opacity-50" diff --git a/apps/web-foldkit/src/core/components/ui/form-error-summary.styles.ts b/apps/web-foldkit/src/core/components/ui/form-error-summary.styles.ts deleted file mode 100644 index 84f704f8b..000000000 --- a/apps/web-foldkit/src/core/components/ui/form-error-summary.styles.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { tv } from "tailwind-variants" - -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const formErrorSummaryStyles = tv({ - base: [ - "rounded-lg border p-4", - "bg-danger-subtle/50 border-danger-subtle-fg/20", - "text-danger-subtle-fg", - "animate-[field-error-enter_0.2s_ease-out]", - ], -}) - -export const formErrorSummaryPartStyles = { - title: "mb-2 font-medium text-sm", - list: "list-inside list-disc space-y-1 text-sm", - field: "font-medium", -} as const diff --git a/apps/web-foldkit/src/core/components/ui/input-otp.styles.ts b/apps/web-foldkit/src/core/components/ui/input-otp.styles.ts deleted file mode 100644 index 2e07ea129..000000000 --- a/apps/web-foldkit/src/core/components/ui/input-otp.styles.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const inputOtpStyles = { - /** InputOTP's wrapper span. */ - wrapper: "relative block", - input: "disabled:cursor-not-allowed", - // `has-[input:disabled]`, not `has-disabled`: Chrome cannot narrow `:has(:not([data-rac]):disabled)`, - // so that rule restyled the whole document on every DOM insertion (S4 heavy-dataset scroll). - control: "flex items-center gap-2 has-[input:disabled]:opacity-50", - group: "flex items-center", - slot: "relative flex size-9 items-center justify-center border-input border-y border-r shadow-xs outline-none transition-all [--input-otp-radius:calc(var(--radius-lg)-1px)] first:rounded-l-(--input-otp-radius) first:border-l last:rounded-r-(--input-otp-radius) aria-invalid:border-danger data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:bg-primary-subtle/10 data-[active=true]:ring-3 data-[active=true]:ring-ring/20 data-[active=true]:aria-invalid:border-danger-subtle-fg/70 data-[active=true]:aria-invalid:ring-danger-subtle-fg/20 sm:text-sm/6 dark:data-[active=true]:aria-invalid:ring-danger-subtle-fg/70", - caret: "pointer-events-none absolute inset-0 flex items-center justify-center", - caretBar: "h-4 w-px animate-caret-blink bg-fg duration-1000", - separatorIcon: "size-4", -} as const diff --git a/apps/web-foldkit/src/core/components/ui/input.styles.ts b/apps/web-foldkit/src/core/components/ui/input.styles.ts index 85e7b71c6..1a8e8b7a4 100644 --- a/apps/web-foldkit/src/core/components/ui/input.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/input.styles.ts @@ -1,13 +1,13 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. Composed with `cx(...styles, className)`. */ +/** Composed with `cx(...styles, className)`. */ export const inputStyles = [ "relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]", "text-base/6 text-fg placeholder:text-muted-fg sm:text-sm/6", "border border-input enabled:hover:border-muted-fg/30", "outline-hidden focus:border-ring/70 focus:ring-3 focus:ring-ring/20 focus:enabled:hover:border-ring/80", - "invalid:border-danger-subtle-fg/70 focus:invalid:border-danger-subtle-fg/70 focus:invalid:ring-danger-subtle-fg/20 invalid:enabled:hover:border-danger-subtle-fg/80 focus:invalid:enabled:hover:border-danger-subtle-fg/80", + "data-invalid:border-danger-subtle-fg/70 focus:data-invalid:border-danger-subtle-fg/70 focus:data-invalid:ring-danger-subtle-fg/20 data-invalid:enabled:hover:border-danger-subtle-fg/80 focus:data-invalid:enabled:hover:border-danger-subtle-fg/80", "[&::-ms-reveal]:hidden [&::-webkit-search-cancel-button]:hidden", - "disabled:bg-muted forced-colors:in-disabled:text-[GrayText]", - "in-disabled:bg-muted forced-colors:in-disabled:text-[GrayText]", + "data-disabled:bg-muted forced-colors:in-data-disabled:text-[GrayText]", + "in-data-disabled:bg-muted forced-colors:in-data-disabled:text-[GrayText]", "dark:scheme-dark", ] satisfies string[] diff --git a/apps/web-foldkit/src/core/components/ui/keyboard.styles.ts b/apps/web-foldkit/src/core/components/ui/keyboard.styles.ts index 36dce7efa..d76f27f27 100644 --- a/apps/web-foldkit/src/core/components/ui/keyboard.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/keyboard.styles.ts @@ -1,3 +1,2 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const keyboardStyles = - "hidden font-mono text-[0.80rem]/6 text-current/60 group-hover:text-fg group-focus:text-fg group-focus:opacity-90 group-disabled:opacity-50 lg:inline forced-colors:group-focus:text-[HighlightText" + "hidden font-mono text-[0.80rem]/6 text-current/60 group-hover:text-fg group-data-focused:text-fg group-data-focused:opacity-90 group-data-disabled:opacity-50 lg:inline forced-colors:group-data-focused:text-[HighlightText" diff --git a/apps/web-foldkit/src/core/components/ui/link.styles.ts b/apps/web-foldkit/src/core/components/ui/link.styles.ts index 44d1742ac..a28d1dbf8 100644 --- a/apps/web-foldkit/src/core/components/ui/link.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/link.styles.ts @@ -1,9 +1,9 @@ import type { ClassNameValue } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. Composed with `cx(linkStyles(...), className)`. */ +/** Composed with `cx(linkStyles(...), className)`. */ export const linkStyles = (options: { readonly hasHref: boolean }): ClassNameValue[] => [ "font-medium text-(--text)", "outline-0 outline-offset-2 transition-[color,_opacity] focus-visible:outline-2 focus-visible:outline-ring forced-colors:outline-[Highlight]", - "disabled:cursor-default disabled:text-muted-fg forced-colors:disabled:text-[GrayText]", + "data-disabled:cursor-default data-disabled:text-muted-fg forced-colors:data-disabled:text-[GrayText]", options.hasHref && "cursor-pointer", ] diff --git a/apps/web-foldkit/src/core/components/ui/list-box.styles.ts b/apps/web-foldkit/src/core/components/ui/list-box.styles.ts deleted file mode 100644 index 46d46d37f..000000000 --- a/apps/web-foldkit/src/core/components/ui/list-box.styles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { twJoin, twMerge } from "tailwind-merge" - -/** Shared by the React and Foldkit apps: keep this file framework-free. */ - -export const listBoxBase = - "grid max-h-96 w-full min-w-56 scroll-py-1 grid-cols-[auto_1fr] flex-col gap-y-1 overflow-y-auto overscroll-contain rounded-xl border bg-bg p-1 outline-hidden [scrollbar-width:thin] has-data-[slot=drag-icon]:grid-cols-[auto_auto_1fr] [&::-webkit-scrollbar]:size-0.5 *:[[role='group']+[role=group]]:mt-4 *:[[role='group']+[role=separator]]:mt-1" - -/** The `className` ListBoxItem hands to `dropdownItemStyles`. */ -export const listBoxItemClassName = (hasHref: boolean, className?: string) => - twJoin( - "group not-has-[[slot=description]]:items-start", - "has-data-[slot=drag-icon]:*:[[slot=label]]:col-start-3", - "has-data-[slot=drag-icon]:*:data-[slot=icon]:col-start-2", - hasHref ? "cursor-pointer" : "cursor-default", - className, - ) - -export const listBoxCheckIconClassName = - "-mx-0.5 mr-2 h-[1lh] w-5 shrink-0 group-allows-dragging:col-start-2 sm:w-4" - -export const listBoxSectionClassName = (className?: string) => - twMerge("gap-y-1 *:data-[slot=list-box-item]:last:-mb-1.5", className) diff --git a/apps/web-foldkit/src/core/components/ui/loader.styles.ts b/apps/web-foldkit/src/core/components/ui/loader.styles.ts index 1d24b0740..5ea83fb59 100644 --- a/apps/web-foldkit/src/core/components/ui/loader.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/loader.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const loaderStyles = { svg: "size-4", loader: (variant: string) => [ diff --git a/apps/web-foldkit/src/core/components/ui/menu.styles.ts b/apps/web-foldkit/src/core/components/ui/menu.styles.ts index d70797ae8..3c646422b 100644 --- a/apps/web-foldkit/src/core/components/ui/menu.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/menu.styles.ts @@ -1,6 +1,5 @@ import { tv } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const menuTriggerBase = [ "relative inline text-left outline-hidden focus-visible:ring-1 focus-visible:ring-primary", "*:data-[slot=chevron]:size-5 sm:*:data-[slot=chevron]:size-4", @@ -15,9 +14,9 @@ export const menuPopoverBase = "min-w-32" /** Extra item classes while an item's submenu is open, per intent. */ export const menuItemSubmenuOpen = { - danger: "open:bg-danger-subtle open:text-danger-subtle-fg", - warning: "open:bg-warning-subtle open:text-warning-subtle-fg", - none: "open:bg-accent open:text-accent-fg open:*:data-[slot=icon]:text-accent-fg open:*:[.text-muted-fg]:text-accent-fg", + danger: "data-open:bg-danger-subtle data-open:text-danger-subtle-fg", + warning: "data-open:bg-warning-subtle data-open:text-warning-subtle-fg", + none: "data-open:bg-accent data-open:text-accent-fg data-open:*:data-[slot=icon]:text-accent-fg data-open:*:[.text-muted-fg]:text-accent-fg", } as const export const menuChevronClassName = "absolute right-2 size-3.5" diff --git a/apps/web-foldkit/src/core/components/ui/modal.styles.ts b/apps/web-foldkit/src/core/components/ui/modal.styles.ts index a69da5359..8b9d9d42f 100644 --- a/apps/web-foldkit/src/core/components/ui/modal.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/modal.styles.ts @@ -1,6 +1,5 @@ import { twJoin } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const modalSizes = { "2xs": "sm:max-w-2xs", xs: "sm:max-w-xs", @@ -17,29 +16,32 @@ export const modalSizes = { export type ModalSize = keyof typeof modalSizes -export const modalOverlayClassName = (size: ModalSize, isBlurred: boolean) => +/** + * Overlay motion: panels and backdrops fade (and scale or slide) in from `@starting-style` when they + * render, in 200ms. `prefers-reduced-motion` collapses every transition in `styles.css`. + */ +export const modalBackdropClassName = + "fixed inset-0 bg-overlay-backdrop transition-opacity duration-200 ease-out starting:opacity-0" + +/** Centers the panel (bottom-anchored on small screens); clicks outside the panel reach the backdrop. */ +export const modalLayoutClassName = (size: ModalSize) => twJoin( - "fixed inset-0 z-50 h-(--visual-viewport-height,100vh) bg-overlay-backdrop", - "grid grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr]", + "pointer-events-none fixed inset-0 grid h-dvh grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr]", size === "fullscreen" ? "md:p-3" : "md:p-4", - "entering:fade-in entering:animate-in entering:duration-300 entering:ease-out", - "exiting:fade-out exiting:animate-out exiting:ease-in", - isBlurred && "backdrop-blur-[1px]", ) -/** The `cx` arguments of the modal panel, before the caller's `className`. */ +/** The `twMerge` arguments of the modal panel, before the caller's `className`. */ export const modalContentBase = (size: ModalSize) => [ - "row-start-2 w-full text-left align-middle", + "pointer-events-auto row-start-2 flex w-full flex-col text-left align-middle", "[--visual-viewport-vertical-padding:16px]", size === "fullscreen" ? "sm:rounded-md sm:[--visual-viewport-vertical-padding:16px]" : "sm:rounded-xl sm:[--visual-viewport-vertical-padding:32px]", + "max-h-[calc(100dvh-var(--visual-viewport-vertical-padding))]", "relative overflow-hidden bg-overlay text-overlay-fg", "rounded-t-2xl shadow-lg ring ring-fg/5 dark:ring-border", modalSizes[size], - // Ensure scale animation originates from center for proper visual effect - "origin-center", - "entering:slide-in-from-bottom sm:entering:zoom-in-95 sm:entering:slide-in-from-bottom-0 entering:animate-in entering:duration-300 entering:ease-out", - "exiting:slide-out-to-bottom sm:exiting:zoom-out-95 sm:exiting:slide-out-to-bottom-0 exiting:animate-out exiting:ease-in", + "origin-center transition-[opacity,translate,scale] duration-200 ease-out", + "starting:translate-y-4 starting:opacity-0 sm:starting:translate-y-0 sm:starting:scale-95", ] as const diff --git a/apps/web-foldkit/src/core/components/ui/popover.styles.ts b/apps/web-foldkit/src/core/components/ui/popover.styles.ts index cc60d612f..54d56b234 100644 --- a/apps/web-foldkit/src/core/components/ui/popover.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/popover.styles.ts @@ -1,16 +1,27 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ +/** + * Popover panel classes, merged with the caller's `className`. Anchor writes `data-placement` + * (top|bottom|left|right) and `--button-width`; the panel fades in from `@starting-style` (150ms). + */ export const popoverContentBase = [ - "group/popover min-w-(--trigger-width) max-w-xs origin-(--trigger-anchor-point) rounded-(--popover-radius) border border-fg/10 bg-overlay text-overlay-fg shadow-xs outline-hidden transition-transform [--gutter:--spacing(6)] [--popover-radius:var(--radius-xl)] sm:text-sm dark:backdrop-saturate-200 **:[[role=dialog]]:[--gutter:--spacing(6)]", - "entering:fade-in exiting:fade-out entering:animate-in exiting:animate-out", - "placement-left:entering:slide-in-from-right-1 placement-right:entering:slide-in-from-left-1 placement-top:entering:slide-in-from-bottom-1 placement-bottom:entering:slide-in-from-top-1", - "placement-left:exiting:slide-out-to-right-1 placement-right:exiting:slide-out-to-left-1 placement-top:exiting:slide-out-to-bottom-1 placement-bottom:exiting:slide-out-to-top-1", + "group/popover z-50 min-w-(--trigger-width) max-w-xs rounded-(--popover-radius) border border-fg/10 bg-overlay text-overlay-fg shadow-xs outline-hidden [--gutter:--spacing(6)] [--popover-radius:var(--radius-xl)] sm:text-sm dark:backdrop-saturate-200 **:[[role=dialog]]:[--gutter:--spacing(6)]", + "flex flex-col transition-opacity duration-150 ease-out starting:opacity-0", "forced-colors:bg-[Canvas]", ] as const -export const popoverArrowClassName = - "block fill-overlay stroke-border group-placement-bottom:rotate-180 group-placement-left:-rotate-90 group-placement-right:rotate-90 forced-colors:fill-[Canvas] forced-colors:stroke-[ButtonBorder]" +/** The click-outside backdrop: transparent, under the panel. */ +export const popoverBackdropClassName = "fixed inset-0 z-40" -export const popoverInnerClassName = "max-h-[inherit] overflow-y-auto" +/** The arrow sits outside the panel edge facing the trigger, at Anchor's `--arrow-x`/`--arrow-y`. */ +export const popoverArrowClassName = [ + "absolute block left-(--arrow-x) top-(--arrow-y) fill-overlay stroke-border", + "group-data-[placement=top]/popover:top-full", + "group-data-[placement=bottom]/popover:bottom-full group-data-[placement=bottom]/popover:rotate-180", + "group-data-[placement=left]/popover:left-full group-data-[placement=left]/popover:-rotate-90", + "group-data-[placement=right]/popover:right-full group-data-[placement=right]/popover:rotate-90", + "forced-colors:fill-[Canvas] forced-colors:stroke-[ButtonBorder]", +].join(" ") + +export const popoverInnerClassName = "min-h-0 flex-1 overflow-y-auto outline-hidden" /** Merged with the footer's `className` by `twMerge`. */ export const popoverFooterBase = "justify-start has-[button]:justify-end" diff --git a/apps/web-foldkit/src/core/components/ui/progress-bar.styles.ts b/apps/web-foldkit/src/core/components/ui/progress-bar.styles.ts index 133715db8..58947f42e 100644 --- a/apps/web-foldkit/src/core/components/ui/progress-bar.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/progress-bar.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const progressBarStyles = { root: [ "w-full", @@ -10,8 +9,6 @@ export const progressBarStyles = { "[&>[data-slot=progress-bar-track]+[slot=errorMessage]]:mt-2", "*:data-[slot=progress-bar-header]:font-medium", ], - header: "flex items-center justify-between", - value: "text-base/6 sm:text-sm/6", track: "relative block w-full", keyframes: ` @keyframes progress-slide { diff --git a/apps/web-foldkit/src/core/components/ui/radio.styles.ts b/apps/web-foldkit/src/core/components/ui/radio.styles.ts index a0f97b68c..9f5727d18 100644 --- a/apps/web-foldkit/src/core/components/ui/radio.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/radio.styles.ts @@ -1,12 +1,11 @@ import type { ClassNameValue } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const radioGroupStyles = [ "space-y-3 **:data-[slot=label]:font-normal", "has-[slot=description]:space-y-6 has-[slot=description]:**:data-[slot=label]:font-medium", ] -export const radioStyles = "group block disabled:opacity-50" +export const radioStyles = "group block data-disabled:opacity-50" /** The grid inside the radio label: `twMerge(...radioLayoutStyles)`. */ export const radioLayoutStyles = [ @@ -19,24 +18,20 @@ export const radioLayoutStyles = [ export interface RadioIndicatorState { readonly isSelected: boolean - readonly isFocusVisible: boolean readonly isInvalid: boolean } -/** The dot: `twMerge(radioIndicatorStyles(state))`. */ +/** The dot: `twMerge(radioIndicatorStyles(state))`. The focus ring follows the label (`group`). */ export const radioIndicatorStyles = ({ isSelected, - isFocusVisible, isInvalid, }: RadioIndicatorState): ClassNameValue[] => [ "relative inset-ring inset-ring-input isolate flex size-4.5 shrink-0 items-center justify-center rounded-full text-bg transition before:absolute before:inset-auto before:size-2 before:shrink-0 before:rounded-full before:content-[''] hover:before:bg-muted-fg/20 sm:size-4 sm:before:size-1.7", isSelected && [ "inset-ring-primary bg-primary text-primary-fg before:bg-bg hover:before:bg-muted/90", - "group-invalid:inset-ring-danger-subtle-fg/70 group-invalid:bg-danger group-invalid:text-danger-fg", - ], - isFocusVisible && [ - "inset-ring-primary ring-3 ring-ring/20", - "group-invalid:inset-ring-danger-subtle-fg/70 group-invalid:text-danger-fg group-invalid:ring-danger-subtle-fg/20", + "group-data-invalid:inset-ring-danger-subtle-fg/70 group-data-invalid:bg-danger group-data-invalid:text-danger-fg", ], + "group-has-focus-visible:inset-ring-primary group-has-focus-visible:ring-3 group-has-focus-visible:ring-ring/20", + "group-has-focus-visible:group-data-invalid:inset-ring-danger-subtle-fg/70 group-has-focus-visible:group-data-invalid:text-danger-fg group-has-focus-visible:group-data-invalid:ring-danger-subtle-fg/20", isInvalid && "inset-ring-danger-subtle-fg/70 bg-danger-subtle/5 text-danger-fg ring-danger-subtle-fg/20", ] diff --git a/apps/web-foldkit/src/core/components/ui/search-field.styles.ts b/apps/web-foldkit/src/core/components/ui/search-field.styles.ts index 6881e7bee..0d7499286 100644 --- a/apps/web-foldkit/src/core/components/ui/search-field.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/search-field.styles.ts @@ -1,12 +1,11 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const searchFieldStyles = { /** `fieldStyles({ className: field })` on the SearchField root. */ field: "group/search-field", group: "[--input-gutter-end:--spacing(8)]", - icon: "in-disabled:opacity-50", + icon: "in-data-disabled:opacity-50", /** twJoin'd onto the clear button. */ clearButton: [ - "touch-target grid place-content-center pressed:text-fg text-muted-fg hover:text-fg group-empty/search-field:invisible", + "touch-target grid place-content-center data-pressed:text-fg text-muted-fg hover:text-fg group-empty/search-field:invisible", "px-3 py-2 sm:px-2.5 sm:py-1.5 sm:text-sm/5", ], clearIcon: "size-5 sm:size-4", diff --git a/apps/web-foldkit/src/core/components/ui/section-footer.styles.ts b/apps/web-foldkit/src/core/components/ui/section-footer.styles.ts deleted file mode 100644 index 039688f1e..000000000 --- a/apps/web-foldkit/src/core/components/ui/section-footer.styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const sectionFooterStyles = { - root: "flex items-center border-border border-t", - rootCard: "gap-4 px-4 py-3 md:py-4 lg:px-6", - rootPlain: "gap-5 pt-4 md:pt-5", - actions: "flex flex-1 justify-end gap-3", -} as const diff --git a/apps/web-foldkit/src/core/components/ui/section-header.styles.ts b/apps/web-foldkit/src/core/components/ui/section-header.styles.ts index 50635bd86..60857ff3a 100644 --- a/apps/web-foldkit/src/core/components/ui/section-header.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/section-header.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const sectionHeaderStyles = { root: "flex flex-col gap-5 border-border border-b pb-5", group: "relative flex flex-col items-start gap-4 md:flex-row", diff --git a/apps/web-foldkit/src/core/components/ui/section-label.styles.ts b/apps/web-foldkit/src/core/components/ui/section-label.styles.ts index 789808b74..78610c9f7 100644 --- a/apps/web-foldkit/src/core/components/ui/section-label.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/section-label.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const sectionLabelStyles = { sizes: { sm: { @@ -14,5 +13,4 @@ export const sectionLabelStyles = { required: "hidden text-primary", requiredShown: "block", description: "text-muted-fg", - actions: "mt-3 flex gap-2", } as const diff --git a/apps/web-foldkit/src/core/components/ui/select.styles.ts b/apps/web-foldkit/src/core/components/ui/select.styles.ts index c85657737..631130b0b 100644 --- a/apps/web-foldkit/src/core/components/ui/select.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/select.styles.ts @@ -1,6 +1,5 @@ import { twJoin } from "tailwind-merge" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const selectPopoverBase = "min-w-(--trigger-width) scroll-py-1 overflow-y-auto overscroll-contain" export const selectListBoxBase = @@ -13,13 +12,13 @@ export const selectTriggerBase = [ "group/select-trigger flex w-full min-w-0 cursor-default items-center gap-x-2 rounded-lg border border-input px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-start text-fg outline-hidden transition duration-200 sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 sm:*:text-sm/6 dark:shadow-none", "focus:border-ring/70 focus:bg-primary-subtle/5 focus:ring-3 focus:ring-ring/20 focus:enabled:hover:border-ring/80", "enabled:hover:border-muted-fg/30", - "group-open/select:border-ring/70 group-open/select:bg-primary-subtle/5 group-open/select:ring-3 group-open/select:ring-ring/20", - "group-open/select:invalid:border-danger-subtle-fg/70 group-open/select:invalid:bg-danger-subtle/5 group-open/select:invalid:ring-3 group-open/select:invalid:ring-danger-subtle-fg/20 group-invalid/select:border-danger-subtle-fg/70 group-invalid/select:bg-danger-subtle/5 group-invalid/select:ring-danger-subtle-fg/20 group-invalid/select:enabled:hover:border-danger-subtle-fg/80 group-focus/select:group-invalid/select:border-danger-subtle-fg/70 group-focus/select:group-invalid/select:ring-danger-subtle-fg/20 group-focus/select:group-invalid/select:enabled:hover:border-danger-subtle-fg/80", - "*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) pressed:*:data-[slot=icon]:text-(--btn-icon-active) focus-visible:*:data-[slot=icon]:text-(--btn-icon-active)/80 enabled:hover:*:data-[slot=icon]:text-(--btn-icon-active)/90 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:hover:[--btn-icon:ButtonText]", + "group-data-open/select:border-ring/70 group-data-open/select:bg-primary-subtle/5 group-data-open/select:ring-3 group-data-open/select:ring-ring/20", + "group-data-open/select:data-invalid:border-danger-subtle-fg/70 group-data-open/select:data-invalid:bg-danger-subtle/5 group-data-open/select:data-invalid:ring-3 group-data-open/select:data-invalid:ring-danger-subtle-fg/20 group-data-invalid/select:border-danger-subtle-fg/70 group-data-invalid/select:bg-danger-subtle/5 group-data-invalid/select:ring-danger-subtle-fg/20 group-data-invalid/select:enabled:hover:border-danger-subtle-fg/80 group-data-focused/select:group-data-invalid/select:border-danger-subtle-fg/70 group-data-focused/select:group-data-invalid/select:ring-danger-subtle-fg/20 group-data-focused/select:group-data-invalid/select:enabled:hover:border-danger-subtle-fg/80", + "*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) data-pressed:*:data-[slot=icon]:text-(--btn-icon-active) focus-visible:*:data-[slot=icon]:text-(--btn-icon-active)/80 enabled:hover:*:data-[slot=icon]:text-(--btn-icon-active)/90 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:hover:[--btn-icon:ButtonText]", "*:data-[slot=loader]:size-5 *:data-[slot=loader]:shrink-0 *:data-[slot=loader]:self-center *:data-[slot=loader]:text-(--btn-icon) sm:*:data-[slot=loader]:size-4", - "forced-colors:group-focus/select:border-[Highlight] forced-colors:group-invalid/select:border-[Mark] forced-colors:group-focus/select:group-invalid/select:border-[Mark]", - "group-disabled/select:bg-muted group-disabled/select:opacity-50 forced-colors:group-disabled/select:border-[GrayText] forced-colors:group-disabled/select:text-[GrayText]", - "in-disabled:bg-muted in-disabled:opacity-50 forced-colors:in-disabled:border-[GrayText] forced-colors:in-disabled:text-[GrayText]", + "forced-colors:group-data-focused/select:border-[Highlight] forced-colors:group-data-invalid/select:border-[Mark] forced-colors:group-data-focused/select:group-data-invalid/select:border-[Mark]", + "group-data-disabled/select:bg-muted group-data-disabled/select:opacity-50 forced-colors:group-data-disabled/select:border-[GrayText] forced-colors:group-data-disabled/select:text-[GrayText]", + "in-data-disabled:bg-muted in-data-disabled:opacity-50 forced-colors:in-data-disabled:border-[GrayText] forced-colors:in-data-disabled:text-[GrayText]", "dark:scheme-dark", ] as const diff --git a/apps/web-foldkit/src/core/components/ui/separator.styles.ts b/apps/web-foldkit/src/core/components/ui/separator.styles.ts index 8cdf0a872..8b0a7ffca 100644 --- a/apps/web-foldkit/src/core/components/ui/separator.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/separator.styles.ts @@ -1,4 +1,3 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export const separatorStyles = { base: "shrink-0 border-0 bg-border forced-colors:bg-[ButtonBorder]", horizontal: "h-px w-full", diff --git a/apps/web-foldkit/src/core/components/ui/sheet.styles.ts b/apps/web-foldkit/src/core/components/ui/sheet.styles.ts index 88131818d..08bdbc2bc 100644 --- a/apps/web-foldkit/src/core/components/ui/sheet.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/sheet.styles.ts @@ -1,7 +1,5 @@ -import { twJoin } from "tailwind-merge" import { tv } from "tailwind-variants" -/** Shared by the React and Foldkit apps: keep this file framework-free. */ export type Sides = "top" | "bottom" | "left" | "right" const generateCompoundVariants = (sides: Array) => { return sides.map((side) => ({ @@ -18,23 +16,18 @@ const generateCompoundVariants = (sides: Array) => { })) } +/** The panel slides in from its side and the backdrop fades, both from `@starting-style` (200ms). */ export const sheetContentStyles = tv({ base: [ "fixed z-50 grid gap-4 border-muted-fg/20 bg-overlay text-overlay-fg shadow-lg dark:border-border", - "transform-gpu transition ease-in-out will-change-transform", + "transform-gpu transition-[opacity,translate] duration-200 ease-out starting:opacity-0", ], variants: { - isEntering: { - true: "fade-in animate-in duration-500", - }, - isExiting: { - true: "fade-in animate-out duration-300", - }, side: { - top: "entering:slide-in-from-top exiting:slide-out-to-top inset-x-0 top-0 rounded-b-2xl border-b", - bottom: "entering:slide-in-from-bottom exiting:slide-out-to-bottom inset-x-0 bottom-0 rounded-t-2xl border-t", - left: "entering:slide-in-from-left exiting:slide-out-to-left-80 inset-y-0 left-0 h-auto w-3/4 overflow-y-auto border-r sm:max-w-80", - right: "entering:slide-in-from-right exiting:slide-out-to-right-80 inset-y-0 right-0 h-auto w-3/4 overflow-y-auto border-l sm:max-w-80", + top: "inset-x-0 top-0 rounded-b-2xl border-b starting:-translate-y-full", + bottom: "inset-x-0 bottom-0 rounded-t-2xl border-t starting:translate-y-full", + left: "inset-y-0 left-0 h-auto w-3/4 overflow-y-auto border-r starting:-translate-x-full sm:max-w-80", + right: "inset-y-0 right-0 h-auto w-3/4 overflow-y-auto border-l starting:translate-x-full sm:max-w-80", }, isFloat: { false: "border-fg/20 dark:border-border", @@ -44,12 +37,7 @@ export const sheetContentStyles = tv({ compoundVariants: generateCompoundVariants(["top", "bottom", "left", "right"]), }) -export const sheetOverlayClassName = (isEntering: boolean, isExiting: boolean, isBlurred: boolean) => - twJoin( - "fixed inset-0 z-50 h-(--visual-viewport-height,100vh) w-screen overflow-hidden bg-overlay-backdrop", - isEntering && "fade-in animate-in duration-500", - isExiting && "fade-out animate-out duration-300", - isBlurred && "backdrop-blur-sm backdrop-filter", - ) +export const sheetBackdropClassName = + "fixed inset-0 h-dvh w-screen overflow-hidden bg-overlay-backdrop transition-opacity duration-200 ease-out starting:opacity-0" export const sheetCloseIconClassName = "top-2.5 right-2.5" diff --git a/apps/web-foldkit/src/core/components/ui/slider.styles.ts b/apps/web-foldkit/src/core/components/ui/slider.styles.ts index 0a3ac4ca8..640c65cf3 100644 --- a/apps/web-foldkit/src/core/components/ui/slider.styles.ts +++ b/apps/web-foldkit/src/core/components/ui/slider.styles.ts @@ -1,40 +1,23 @@ -/** Shared by the React and Foldkit apps: keep this file framework-free. */ -export const sliderGroupStyles = "flex items-center gap-x-3 *:data-[slot=icon]:size-5" - -/** Slider root: `cx(...sliderStyles, className)`. */ +/** Slider root (`@foldkit/ui` Slider's `root` attributes): `twMerge(...sliderStyles)`. */ export const sliderStyles = [ - "group relative flex touch-none select-none flex-col disabled:opacity-50", - "orientation-horizontal:w-full orientation-horizontal:min-w-fit orientation-horizontal:gap-y-2", - "orientation-vertical:h-full orientation-vertical:min-h-fit orientation-vertical:w-1.5 orientation-vertical:items-center orientation-vertical:gap-y-2", + "group relative flex touch-none select-none flex-col data-disabled:opacity-50", + "data-[orientation=horizontal]:w-full data-[orientation=horizontal]:min-w-fit data-[orientation=horizontal]:gap-y-2", + "data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-fit data-[orientation=vertical]:w-1.5 data-[orientation=vertical]:items-center data-[orientation=vertical]:gap-y-2", ] export const sliderOutputStyles = "font-medium text-base/6 sm:text-sm/6" +/** The thumb; `@foldkit/ui` positions it along the track with inline `left` and `translateX`. */ export const sliderThumbStyles = - "top-[50%] left-[50%] size-[1.25rem] rounded-full border border-fg/10 bg-white outline-hidden ring-black transition-[width,height]" + "top-1/2 size-5 -translate-y-1/2 cursor-grab rounded-full border border-fg/10 bg-white shadow-sm outline-hidden transition-[width,height] focus-visible:border-ring/70 focus-visible:ring-3 focus-visible:ring-ring/20 data-dragging:cursor-grabbing data-disabled:cursor-default" /** Slider track: `cx(...sliderTrackStyles, className)`. */ export const sliderTrackStyles = [ "bg-(--slider-track-bg,var(--color-secondary))", "group/track relative cursor-default rounded-full", - "grow group-orientation-horizontal:h-1.5 group-orientation-horizontal:w-full group-orientation-vertical:w-1.5 group-orientation-vertical:flex-1", - "disabled:cursor-default disabled:opacity-60", + "grow group-data-[orientation=horizontal]:h-1.5 group-data-[orientation=horizontal]:w-full group-data-[orientation=vertical]:w-1.5 group-data-[orientation=vertical]:flex-1", + "data-disabled:cursor-default data-disabled:opacity-60", ] export const sliderFillStyles = - "group-orientation-horizontal/top-0 pointer-events-none absolute rounded-full bg-primary group-disabled/track:opacity-60 group-orientation-vertical/track:bottom-0 group-orientation-horizontal/track:h-full group-orientation-vertical/track:w-full" - -/** SliderFill's inline style for one thumb (a width) or a range (offset plus width). */ -export const sliderFillStyle = ( - orientation: "horizontal" | "vertical", - percents: ReadonlyArray, -): Readonly> => { - const percent0 = percents[0] ?? 0 - const percent1 = percents[1] ?? 0 - if (percents.length === 1) { - return orientation === "horizontal" ? { width: `${percent0}%` } : { height: `${percent0}%` } - } - return orientation === "horizontal" - ? { left: `${percent0}%`, width: `${Math.abs(percent0 - percent1)}%` } - : { bottom: `${percent0}%`, height: `${Math.abs(percent0 - percent1)}%` } -} + "pointer-events-none absolute rounded-full bg-primary group-data-disabled/track:opacity-60 group-data-[orientation=vertical]/track:bottom-0 group-data-[orientation=horizontal]/track:h-full group-data-[orientation=vertical]/track:w-full" diff --git a/apps/web-foldkit/src/core/components/ui/sonner.styles.ts b/apps/web-foldkit/src/core/components/ui/sonner.styles.ts deleted file mode 100644 index b52121a18..000000000 --- a/apps/web-foldkit/src/core/components/ui/sonner.styles.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * sonner's stylesheet as a string, for the Foldkit toaster port. sonner injects the same rules - * into a ` - - -
-

UI parity · ${summary.candidate} vs ${summary.baseline}

- identical ${summary.totals.identical} - pass ${summary.totals.pass} - fail ${summary.totals.fail} - missing ${summary.totals.missing} - - ${summary.run} · ${summary.createdAt} -
-
- -
-
- - -` diff --git a/packages/ui-parity/src/runtime/clerk-stub.ts b/packages/ui-parity/src/runtime/clerk-stub.ts deleted file mode 100644 index 286d05c21..000000000 --- a/packages/ui-parity/src/runtime/clerk-stub.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Browser-side stand-in for clerk-js, installed via `page.addInitScript` before the - * app boots. `@clerk/react` sees `window.Clerk.loaded === true` and skips downloading - * clerk-js / clerk-ui entirely, so auth is deterministic and offline. - * - * Serialized into the page with `Function.prototype.toString`, so it must be - * self-contained (no imports, no closures over module scope). - */ -export interface ClerkStubIdentity { - readonly clerkUserId: string - readonly email: string - readonly firstName: string - readonly lastName: string - readonly imageUrl: string - readonly clerkOrgId: string | null -} - -/** `null` installs a signed-out Clerk: no client session, no user, no organization. */ -export const installClerkStub = (identity: ClerkStubIdentity | null) => { - const noop = () => {} - const asyncNoop = async () => {} - const signedIn = (id: ClerkStubIdentity) => { - const user = { - id: id.clerkUserId, - firstName: id.firstName, - lastName: id.lastName, - fullName: `${id.firstName} ${id.lastName}`, - imageUrl: id.imageUrl, - hasImage: true, - primaryEmailAddress: { emailAddress: id.email }, - emailAddresses: [{ emailAddress: id.email }], - externalAccounts: [], - organizationMemberships: id.clerkOrgId - ? [{ organization: { id: id.clerkOrgId }, role: "org:admin", permissions: [] }] - : [], - publicMetadata: {}, - unsafeMetadata: {}, - reload: asyncNoop, - update: asyncNoop, - } - const organization = id.clerkOrgId ? { id: id.clerkOrgId, name: "Org", slug: "org" } : null - const session = { - id: "sess_parity", - status: "active", - user, - lastActiveOrganizationId: id.clerkOrgId, - factorVerificationAge: null, - lastActiveToken: { - getRawString: () => "parity-token", - jwt: { - claims: { - sub: id.clerkUserId, - sid: "sess_parity", - org_id: id.clerkOrgId ?? undefined, - }, - }, - }, - getToken: async () => "parity-token", - checkAuthorization: () => true, - touch: asyncNoop, - } - const client = { - sessions: [session], - signedInSessions: [session], - activeSessions: [session], - lastActiveSessionId: session.id, - } - return { client, session, user, organization } - } - const signedOut = { - client: { sessions: [], signedInSessions: [], activeSessions: [], lastActiveSessionId: null }, - session: null, - user: null, - organization: null, - } - const resources = identity ? signedIn(identity) : signedOut - const { client, session, user, organization } = resources - const listeners = new Set<(r: typeof resources) => void>() - - const base: Record = { - loaded: true, - status: "ready", - version: "parity-stub", - sdkMetadata: { name: "parity-stub", version: "0" }, - instanceType: "development", - frontendApi: "parity.clerk.invalid", - publishableKey: "pk_test_parity", - isSatellite: false, - isSignedIn: identity !== null, - client, - session, - user, - organization, - telemetry: { record: noop }, - load: asyncNoop, - __internal_lastEmittedResources: resources, - addListener: (listener: (r: typeof resources) => void, options?: { skipInitialEmit?: boolean }) => { - listeners.add(listener) - if (!options?.skipInitialEmit) listener(resources) - return () => listeners.delete(listener) - }, - on: noop, - off: noop, - __internal_updateProps: noop, - __internal_setStatus: noop, - setActive: asyncNoop, - signOut: asyncNoop, - redirectToSignIn: asyncNoop, - buildUrlWithAuth: (url: string) => url, - buildSignInUrl: () => "/sign-in", - buildSignUpUrl: () => "/sign-up", - buildAfterSignOutUrl: () => "/", - getOrganizationMemberships: async () => ({ data: [], total_count: 0 }), - // Mount helpers for prebuilt components render nothing in parity runs. - mountSignIn: noop, - unmountSignIn: noop, - mountSignUp: noop, - unmountSignUp: noop, - mountCreateOrganization: noop, - unmountCreateOrganization: noop, - } - - // Clerk's React layer calls a long tail of internal/UI methods (`__internal_*`, - // `mountUserButton`, `openSignIn`, ...). None of them affect rendering, so they no-op. - const isNoopMethod = (prop: string) => - /^(__internal_|mount|unmount|open|close|navigate|handle)/.test(prop) - - const unknownAccesses = new Set() - ;(window as unknown as { __parityClerkUnknown: Set }).__parityClerkUnknown = unknownAccesses - ;(window as unknown as { Clerk: unknown }).Clerk = new Proxy(base, { - get(target, prop, receiver) { - if (typeof prop === "string" && prop !== "then" && !(prop in target)) { - if (isNoopMethod(prop)) return asyncNoop - unknownAccesses.add(prop) - } - return Reflect.get(target, prop, receiver) - }, - }) -} diff --git a/packages/ui-parity/src/runtime/snapshot.ts b/packages/ui-parity/src/runtime/snapshot.ts deleted file mode 100644 index 6d7be5996..000000000 --- a/packages/ui-parity/src/runtime/snapshot.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Structural snapshot of what is on screen, collected inside the page. - * - * The two apps will not share DOM structure (React Aria wrappers vs Foldkit views), - * so elements are matched on what the user perceives instead: - * - * - text runs: every visible text node, keyed by its text (+ occurrence index) - * - controls: elements with an accessible role, keyed by role + name - * - * Each record carries its box and the computed styles that decide how it looks, - * so a diff can say "button 'Attach': padding-left 8px → 6px" instead of only - * "some pixels are red". - */ - -export const TEXT_STYLE_PROPS = [ - "font-family", - "font-size", - "font-weight", - "font-style", - "line-height", - "letter-spacing", - "color", - "text-decoration-line", - "text-transform", - "opacity", -] as const - -export const BOX_STYLE_PROPS = [ - "background-color", - "background-image", - "color", - "border-top-width", - "border-right-width", - "border-bottom-width", - "border-left-width", - "border-top-color", - "border-top-style", - "border-top-left-radius", - "border-top-right-radius", - "border-bottom-left-radius", - "border-bottom-right-radius", - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - "box-shadow", - "outline-style", - "outline-width", - "outline-color", - "opacity", - "gap", - "font-size", - "font-weight", - "cursor", -] as const - -export interface Rect { - readonly x: number - readonly y: number - readonly width: number - readonly height: number -} - -export interface SnapshotNode { - readonly kind: "text" | "control" - /** Stable identity used to pair nodes across the two apps. */ - readonly key: string - readonly label: string - readonly rect: Rect - readonly styles: Record - /** CSS-ish path for humans, e.g. `nav > ul > li:nth-child(2) > a`. */ - readonly path: string -} - -export interface DomSnapshot { - readonly viewport: { readonly width: number; readonly height: number } - readonly nodes: ReadonlyArray -} - -export const collectSnapshot = (input: { - textProps: ReadonlyArray - boxProps: ReadonlyArray -}): DomSnapshot => { - const round = (value: number) => Math.round(value * 10) / 10 - const toRect = (rect: DOMRect): Rect => ({ - x: round(rect.x), - y: round(rect.y), - width: round(rect.width), - height: round(rect.height), - }) - const vw = window.innerWidth - const vh = window.innerHeight - const onScreen = (rect: DOMRect) => - rect.width > 0 && - rect.height > 0 && - rect.bottom > 0 && - rect.right > 0 && - rect.top < vh && - rect.left < vw - - const isVisible = (element: Element) => { - const style = getComputedStyle(element) - return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) > 0.01 - } - - const pathOf = (element: Element) => { - const parts: string[] = [] - let node: Element | null = element - while (node && node !== document.body && parts.length < 6) { - const parent: Element | null = node.parentElement - let part = node.tagName.toLowerCase() - if (parent) { - const siblings = [...parent.children].filter((child) => child.tagName === node!.tagName) - if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node) + 1})` - } - parts.unshift(part) - node = parent - } - return parts.join(" > ") - } - - const pick = (element: Element, props: ReadonlyArray) => { - const style = getComputedStyle(element) - return Object.fromEntries(props.map((prop) => [prop, style.getPropertyValue(prop)])) - } - - const occurrences = new Map() - const keyed = (base: string) => { - const count = occurrences.get(base) ?? 0 - occurrences.set(base, count + 1) - return count === 0 ? base : `${base}#${count + 1}` - } - - const nodes: SnapshotNode[] = [] - - // Text runs, in document order. - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT) - for (let text = walker.nextNode(); text; text = walker.nextNode()) { - const content = text.textContent?.replace(/\s+/g, " ").trim() - const parent = text.parentElement - if (!content || !parent || parent.closest("script, style, [data-parity-ignore]")) continue - if (!isVisible(parent)) continue - const range = document.createRange() - range.selectNodeContents(text) - const rect = range.getBoundingClientRect() - if (!onScreen(rect)) continue - nodes.push({ - kind: "text", - key: keyed(`text:${content.slice(0, 80)}`), - label: content.slice(0, 80), - rect: toRect(rect), - styles: pick(parent, input.textProps), - path: pathOf(parent), - }) - } - - // Controls and landmarks with an accessible role. - const roleSelector = - "button, a[href], input, textarea, select, [role], [contenteditable=true], img, svg, nav, aside, header, main, dialog" - for (const element of document.body.querySelectorAll(roleSelector)) { - if (element.closest("[data-parity-ignore]")) continue - const rect = element.getBoundingClientRect() - if (!onScreen(rect) || !isVisible(element)) continue - // Inline SVGs inside a control are covered by the control itself. - if (element.tagName === "svg" && element.parentElement?.closest("button, a, [role]")) continue - const role = element.getAttribute("role") ?? element.tagName.toLowerCase() - const name = ( - element.getAttribute("aria-label") ?? - element.getAttribute("alt") ?? - element.getAttribute("placeholder") ?? - element.getAttribute("title") ?? - element.textContent ?? - "" - ) - .replace(/\s+/g, " ") - .trim() - .slice(0, 60) - nodes.push({ - kind: "control", - key: keyed(`${role}:${name}`), - label: `${role} "${name}"`, - rect: toRect(rect), - styles: pick(element, input.boxProps), - path: pathOf(element), - }) - } - - return { viewport: { width: vw, height: vh }, nodes } -} - -/** - * Indented serialization of the rendered body: the reference markup a port has to - * reproduce (tags, classes, data-/aria- attributes, SVG). Runs inside the page. - */ -export const serializeDom = (): string => { - const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"]) - const VOID = new Set([ - "AREA", - "BR", - "COL", - "EMBED", - "HR", - "IMG", - "INPUT", - "LINK", - "META", - "SOURCE", - "TRACK", - "WBR", - ]) - const lines: string[] = [] - const escape = (value: string) => - value.replace(/&/g, "&").replace(/"/g, """).replace(/ { - const indent = " ".repeat(depth) - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent?.replace(/\s+/g, " ").trim() - if (text) lines.push(indent + escape(text)) - return - } - if (node.nodeType !== Node.ELEMENT_NODE) return - const element = node as Element - if (SKIP.has(element.tagName) || element.hasAttribute("data-parity")) return - const tag = element.tagName.toLowerCase() - // Sorted so DOM dumps diff on content, not on the order a framework happens to set attributes. - const attrs = [...element.attributes] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((attr) => (attr.value === "" ? ` ${attr.name}` : ` ${attr.name}="${escape(attr.value)}"`)) - .join("") - const children = [...element.childNodes] - const onlyText = children.length === 1 && children[0]!.nodeType === Node.TEXT_NODE - if (VOID.has(element.tagName)) lines.push(`${indent}<${tag}${attrs}>`) - else if (children.length === 0) lines.push(`${indent}<${tag}${attrs}>`) - else if (onlyText) - lines.push( - `${indent}<${tag}${attrs}>${escape(children[0]!.textContent?.replace(/\s+/g, " ").trim() ?? "")}`, - ) - else { - lines.push(`${indent}<${tag}${attrs}>`) - for (const child of children) walk(child, depth + 1) - lines.push(`${indent}`) - } - } - const html = document.documentElement - const htmlAttrs = [...html.attributes].map((attr) => ` ${attr.name}="${escape(attr.value)}"`).join("") - lines.push(``) - walk(document.body, 0) - return lines.join("\n") -} diff --git a/packages/ui-parity/src/runtime/stabilize.ts b/packages/ui-parity/src/runtime/stabilize.ts deleted file mode 100644 index f1b0c4697..000000000 --- a/packages/ui-parity/src/runtime/stabilize.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Browser-side determinism, installed with `page.addInitScript` before any app code. - * Serialized via `toString`, so it must be self-contained. - * - * - Math.random is seeded (generated avatars, IDs, jitter all repeat exactly) - * - CSS animations/transitions are frozen at their end state, carets hidden - * - scrollbars hidden (their rendering differs between headless runs) - * - intervals run at most once per frame: RivetKit's keep-alive is `setInterval(fn)` (0 ms), which the - * frozen clock would run back to back, starving every other timer - */ -export const installDeterminism = (seed: number) => { - let state = seed >>> 0 - // Steps call this (via `reseedRandom`) right before an action whose output is random, so the - // result depends on that action alone, not on how many incidental draws (span ids...) came first. - ;(window as unknown as { __parityReseed: (next: number) => void }).__parityReseed = (next) => { - state = next >>> 0 - } - Math.random = () => { - // mulberry32 - state = (state + 0x6d2b79f5) >>> 0 - let t = state - t = Math.imul(t ^ (t >>> 15), t | 1) - t ^= t + Math.imul(t ^ (t >>> 7), t | 61) - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } - - const setIntervalOf = window.setInterval.bind(window) - const clampedSetInterval = (handler: TimerHandler, timeout?: number, ...args: unknown[]) => - setIntervalOf(handler, Math.max(Number(timeout) || 0, 16), ...args) - Reflect.set(window, "setInterval", clampedSetInterval) - - const css = ` - *, *::before, *::after { - animation-delay: -1ms !important; - animation-duration: 1ms !important; - animation-iteration-count: 1 !important; - animation-play-state: paused !important; - transition-duration: 0s !important; - transition-delay: 0s !important; - caret-color: transparent !important; - scroll-behavior: auto !important; - } - ::-webkit-scrollbar { display: none !important; } - * { scrollbar-width: none !important; } - ` - const install = () => { - const style = document.createElement("style") - style.setAttribute("data-parity", "stabilize") - style.textContent = css - document.head.appendChild(style) - } - if (document.head) install() - else document.addEventListener("DOMContentLoaded", install, { once: true }) -} - -/** - * Resolves once the page has visually settled: fonts loaded, images decoded, no DOM mutations for - * `quietMs`, then two consecutive frames with no scroll or layout change. Late image loads and the - * scroll re-anchoring they trigger restart the wait. Runs inside the page via `page.evaluate`. - */ -export const waitForVisualQuiet = async ({ quietMs, timeoutMs }: { quietMs: number; timeoutMs: number }) => { - const deadline = performance.now() + timeoutMs - const untilDeadline = (promise: Promise) => - Promise.race([promise, new Promise((resolve) => setTimeout(resolve, deadline - performance.now()))]) - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)) - const decodeImages = () => - untilDeadline(Promise.all([...document.images].map((image) => image.decode().catch(() => undefined)))) - const mutationQuiet = () => - new Promise((resolve) => { - let timer = setTimeout(done, quietMs) - const observer = new MutationObserver(() => { - clearTimeout(timer) - if (performance.now() > deadline) return done() - timer = setTimeout(done, quietMs) - }) - observer.observe(document.documentElement, { - subtree: true, - childList: true, - attributes: true, - characterData: true, - }) - function done() { - observer.disconnect() - resolve() - } - }) - // Scroll offsets and extents of the window and every scrollable or overflowing element, plus image state. - const layoutSignature = () => { - const root = document.documentElement - const parts: Array = [scrollX, scrollY, root.scrollWidth, root.scrollHeight] - for (const element of document.querySelectorAll("*")) { - const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = element - if (scrollTop || scrollLeft || scrollHeight > clientHeight || scrollWidth > clientWidth) - parts.push(scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth) - } - for (const image of document.images) parts.push(image.complete ? image.naturalWidth : "-") - return parts.join(",") - } - // Infinite Web Animations (motion's `repeat: Infinity`, `element.animate`) never settle. Pause each at - // the last frame of its first iteration, the WAAPI twin of the CSS freeze above. CSS-driven ones are left - // alone. Rate 0 also makes the screenshot's `animations: "disabled"` skip them instead of cancelling. - const freezeInfiniteAnimations = () => { - for (const animation of document.getAnimations()) { - if (animation instanceof CSSAnimation || animation instanceof CSSTransition) continue - const timing = animation.effect?.getComputedTiming() - if (!timing || timing.endTime !== Infinity || typeof timing.duration !== "number") continue - if (animation.playbackRate === 0) continue - animation.pause() - animation.currentTime = (timing.delay ?? 0) + Math.max(0, timing.duration - 0.001) - animation.playbackRate = 0 - } - } - - await document.fonts.ready - for (;;) { - await decodeImages() - freezeInfiniteAnimations() - await mutationQuiet() - freezeInfiniteAnimations() - // SMIL animations (the spin loader's ) ignore the CSS freeze; pin them at t=0. - for (const svg of document.querySelectorAll("svg")) { - svg.pauseAnimations() - svg.setCurrentTime(0) - } - await decodeImages() - // Two frames with nothing moving, so the last change is painted and nothing is still settling. - const first = layoutSignature() - await nextFrame() - const second = layoutSignature() - await nextFrame() - if ((first === second && second === layoutSignature()) || performance.now() > deadline) return - } -} - -/** Restart the seeded `Math.random` sequence. Use right before a step whose visible output is random. */ -export const reseedRandom = ( - page: { evaluate: (fn: (seed: number) => void, seed: number) => Promise }, - seed = 0x5eed, -) => - page.evaluate((next) => { - ;(window as unknown as { __parityReseed: (value: number) => void }).__parityReseed(next) - }, seed) diff --git a/packages/ui-parity/src/scenarios.ts b/packages/ui-parity/src/scenarios.ts deleted file mode 100644 index 6f5c90f60..000000000 --- a/packages/ui-parity/src/scenarios.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Dataset } from "./fixtures/dataset.ts" -import { defaultDataset } from "./fixtures/datasets/default.ts" -import { chatArea } from "./scenarios/chat.ts" -import { chatEmbedsArea } from "./scenarios/chat-embeds.ts" -import { chatListArea } from "./scenarios/chat-list.ts" -import { composerArea } from "./scenarios/composer.ts" -import { entryArea } from "./scenarios/entry.ts" -import { galleryArea } from "./scenarios/gallery.ts" -import { galleryDataArea } from "./scenarios/gallery-data.ts" -import { galleryOverlaysArea } from "./scenarios/gallery-overlays.ts" -import { homeArea } from "./scenarios/home.ts" -import { integrationsArea } from "./scenarios/integrations.ts" -import { mySettingsArea } from "./scenarios/my-settings.ts" -import { navigationArea } from "./scenarios/navigation.ts" -import { notificationsArea } from "./scenarios/notifications.ts" -import { settingsArea } from "./scenarios/settings.ts" -import type { AreaModule, RpcHandlers, Scenario } from "./scenarios/types.ts" - -export * from "./scenarios/types.ts" - -/** Every area module. Scenarios live in `src/scenarios/.ts`; only this list is shared. */ -const areas: ReadonlyArray = [ - chatArea, - chatEmbedsArea, - chatListArea, - navigationArea, - notificationsArea, - settingsArea, - integrationsArea, - mySettingsArea, - homeArea, - entryArea, - galleryArea, - composerArea, - galleryDataArea, - galleryOverlaysArea, -] - -const uniqueBy =
(items: ReadonlyArray, key: (item: A) => string, kind: string) => { - const seen = new Set() - for (const item of items) { - if (seen.has(key(item))) throw new Error(`ui-parity: duplicate ${kind} "${key(item)}"`) - seen.add(key(item)) - } - return items -} - -export const scenarios: ReadonlyArray = uniqueBy( - areas.flatMap((area) => area.scenarios), - (scenario) => scenario.id, - "scenario", -) - -export const datasets: ReadonlyMap = new Map( - uniqueBy( - [defaultDataset, ...areas.flatMap((area) => area.datasets ?? [])], - (dataset) => dataset.name, - "dataset", - ).map((dataset) => [dataset.name, dataset]), -) - -for (const scenario of scenarios) - if (scenario.dataset && !datasets.has(scenario.dataset)) - throw new Error(`ui-parity: scenario "${scenario.id}" uses unknown dataset "${scenario.dataset}"`) - -/** Area-level canned RPCs for a dataset, merged in area order. */ -export const areaRpcHandlers = (dataset: Dataset): RpcHandlers => - Object.assign({}, ...areas.map((area) => area.rpc?.(dataset) ?? {})) diff --git a/packages/ui-parity/src/scenarios/chat-embeds.ts b/packages/ui-parity/src/scenarios/chat-embeds.ts deleted file mode 100644 index 33364e081..000000000 --- a/packages/ui-parity/src/scenarios/chat-embeds.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { embedsChat, embedsDataset } from "../fixtures/datasets/embeds.ts" -import { richChat } from "../fixtures/datasets/rich.ts" -import { parkMouse } from "./chat-overlays.ts" -import type { AreaModule, Scenario } from "./types.ts" - -/** - * Message content served by third parties (link previews, tweets, YouTube, GIFs), AI replies - * streamed by the Rivet message actor, and the image viewer's carousel. The fixture backend stands - * in for the network (`backend/network.ts`, `backend/rivet.ts`). - */ - -const embeds = (scenario: Omit): Scenario => ({ - area: "chat-embeds", - dataset: "embeds", - ...scenario, -}) - -export const chatEmbedsArea: AreaModule = { - datasets: [embedsDataset], - scenarios: [ - embeds({ - id: "chat-embeds-link-preview", - title: "Link previews: with an image, text only, and a URL without a preview", - path: embedsChat("unfurls"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Launch retro: what went well").waitFor() - }, - }), - embeds({ - id: "chat-embeds-tweet", - title: "Tweet card with entities, photos and metrics, and a tweet that is gone", - path: embedsChat("tweets"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Tweet not found").waitFor() - await page.getByRole("link", { name: "@hazelchat" }).waitFor() - }, - }), - embeds({ - id: "chat-embeds-youtube", - title: "YouTube players, one starting at a timestamp", - path: embedsChat("videos"), - steps: async (page) => { - await page.getByTitle("YouTube video player").first().waitFor() - }, - }), - embeds({ - id: "chat-embeds-gif", - title: "GIPHY and KLIPY GIFs with their attribution", - path: embedsChat("gifs"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("link", { name: "via KLIPY" }).waitFor() - }, - }), - embeds({ - id: "chat-embeds-gif-viewer", - title: "A GIF opened in the image viewer", - path: embedsChat("gifs"), - steps: async (page) => { - await page.getByRole("img", { name: "GIF" }).first().click() - await page.getByRole("img", { name: "GIF" }).nth(2).waitFor() - await parkMouse(page) - }, - }), - embeds({ - id: "chat-embeds-ai-final", - title: "AI replies: streamed to completion, from the cache, and failed", - path: embedsChat("ai-replies"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Nothing is blocking the launch.").waitFor() - }, - }), - embeds({ - id: "chat-embeds-ai-streaming", - title: "AI replies mid-stream (progress bar, partial text, cursor) and still thinking", - path: embedsChat("ai-live"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Analyzing deploys").waitFor() - await page.getByText("api", { exact: false }).first().waitFor() - }, - }), - { - id: "chat-embeds-image-carousel", - area: "chat-embeds", - dataset: "rich", - title: "Image viewer carousel: next twice, then a thumbnail", - path: richChat("media"), - steps: async (page) => { - await page.getByText("Moodboard for the launch page").scrollIntoViewIfNeeded() - await page.getByRole("img", { name: "moodboard-1.png" }).first().click() - await page.getByRole("button", { name: "Next image" }).click() - await page.getByText("2 of 5").waitFor() - await page.getByRole("button", { name: "Next image" }).click() - await page.getByText("3 of 5").waitFor() - await page.getByRole("button", { name: "moodboard-5.png" }).last().click() - await page.getByText("5 of 5").waitFor() - await parkMouse(page) - }, - }, - { - id: "chat-embeds-image-carousel-keys", - area: "chat-embeds", - dataset: "rich", - title: "Image viewer carousel driven by the arrow keys", - path: richChat("media"), - steps: async (page) => { - await page.getByText("Moodboard for the launch page").scrollIntoViewIfNeeded() - await page.getByRole("img", { name: "moodboard-2.png" }).first().click() - await page.getByText("2 of 5").waitFor() - await page.keyboard.press("ArrowRight") - await page.getByText("3 of 5").waitFor() - await page.keyboard.press("ArrowLeft") - await page.keyboard.press("ArrowLeft") - await page.getByText("1 of 5").waitFor() - await parkMouse(page) - }, - }, - ], -} diff --git a/packages/ui-parity/src/scenarios/chat-list.ts b/packages/ui-parity/src/scenarios/chat-list.ts deleted file mode 100644 index d704443f1..000000000 --- a/packages/ui-parity/src/scenarios/chat-list.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { heavyDataset, heavyIds } from "../fixtures/datasets/heavy.ts" -import type { AreaModule } from "./types.ts" - -const heavyChat = (channelId: string) => `/${heavyIds.orgSlug}/chat/${channelId}` - -/** Message list and sidebar at scale: 500 channels, a 10,000-message channel. */ -export const chatListArea: AreaModule = { - datasets: [heavyDataset], - scenarios: [ - { - id: "chat-heavy-channel", - area: "chat-list", - title: "10,000-message channel in a 500-channel workspace", - path: heavyChat(heavyIds.bigChannelId), - dataset: heavyDataset.name, - themes: ["light", "dark"], - }, - { - id: "chat-heavy-small-channel", - area: "chat-list", - title: "Normal channel in a 500-channel workspace", - path: heavyChat(heavyIds.smallChannelId), - dataset: heavyDataset.name, - }, - ], -} diff --git a/packages/ui-parity/src/scenarios/chat-overlays.ts b/packages/ui-parity/src/scenarios/chat-overlays.ts deleted file mode 100644 index f0541506c..000000000 --- a/packages/ui-parity/src/scenarios/chat-overlays.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Page } from "playwright" -import { richChat } from "../fixtures/datasets/rich.ts" -import { seedEmojiPicker } from "../fixtures/datasets/rich/emoji.ts" -import type { Scenario } from "./types.ts" - -/** - * Overlays anchored to a message: toolbar menus, context menu, pickers, tooltips, popovers and the - * delete dialog. All on the `rich` dataset's #launch unless noted. - */ - -const overlay = (scenario: Omit & { path?: string }): Scenario => ({ - area: "chat", - dataset: "rich", - ...scenario, - path: scenario.path ?? richChat("launch"), -}) - -/** Moves the pointer off the message list so no hover toolbar lingers in the capture. */ -export const parkMouse = (page: Page) => page.mouse.move(0, 0) - -const LAUNCH_WINDOW = "Launch window confirmed" -const CHECKLIST = "Launch checklist: please claim your items" - -const hoverToolbar = async (page: Page, text: string) => { - await page.getByText(text).hover() - await page.getByRole("toolbar", { name: "Message actions" }).waitFor() -} - -/** - * React Aria opens tooltips on hover only once the interaction modality is "pointer", so click a - * neutral element (the channel heading) first. - */ -const hoverForTooltip = async (page: Page, target: ReturnType) => { - await page.getByRole("heading", { level: 2 }).first().click() - await target.hover() - await page.getByRole("tooltip").waitFor() -} - -export const chatOverlayScenarios: ReadonlyArray = [ - overlay({ - id: "chat-message-context-menu", - title: "Message context menu (right click)", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText(LAUNCH_WINDOW).click({ button: "right" }) - await page.getByRole("menu").waitFor() - }, - }), - overlay({ - id: "chat-message-more-actions", - title: "Message toolbar overflow menu", - steps: async (page) => { - await hoverToolbar(page, CHECKLIST) - await page.getByRole("button", { name: "More actions" }).click() - await page.getByRole("menu").waitFor() - }, - }), - overlay({ - id: "chat-emoji-picker", - title: "Reaction emoji picker with custom emojis", - themes: ["light", "dark"], - steps: async (page) => { - await seedEmojiPicker(page) - await hoverToolbar(page, LAUNCH_WINDOW) - await page.getByRole("button", { name: "Add reaction" }).click() - await page.getByRole("dialog", { name: "Emoji picker" }).waitFor() - }, - }), - overlay({ - id: "chat-emoji-picker-reopen", - title: "Reaction emoji picker reopened after Escape (focus back on the trigger)", - steps: async (page) => { - await seedEmojiPicker(page) - await hoverToolbar(page, LAUNCH_WINDOW) - const picker = page.getByRole("dialog", { name: "Emoji picker" }) - await page.getByRole("button", { name: "Add reaction" }).click() - await picker.waitFor() - await page.keyboard.press("Escape") - await picker.waitFor({ state: "hidden" }) - await page.getByRole("button", { name: "Add reaction" }).click() - await picker.waitFor() - }, - }), - overlay({ - id: "chat-reaction-tooltip", - title: "Reaction tooltip listing who reacted", - steps: async (page) => { - await hoverForTooltip(page, page.getByRole("button", { name: "👍 2" })) - }, - }), - overlay({ - id: "chat-custom-emoji-reaction-tooltip", - title: "Custom emoji reaction tooltip", - steps: async (page) => { - await hoverForTooltip(page, page.getByRole("button", { name: "custom:shipit 2" })) - }, - }), - overlay({ - id: "chat-user-profile-popover", - title: "User profile popover from a message avatar", - themes: ["light", "dark"], - steps: async (page) => { - // Avatars with an image are named by their alt text; generated avatars are unnamed. - await page.getByRole("button", { name: "Katherine Johnson" }).last().click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay({ - id: "chat-bot-profile-popover", - title: "Bot profile popover", - path: richChat("github"), - steps: async (page) => { - await page.getByRole("button", { name: "GitHub" }).last().click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay({ - id: "chat-delete-message-modal", - title: "Delete message confirmation", - steps: async (page) => { - await hoverToolbar(page, CHECKLIST) - await page.getByRole("button", { name: "Delete message" }).click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay({ - id: "chat-reply-indicator", - title: "Replying to a message: indicator above the composer", - steps: async (page) => { - await hoverToolbar(page, LAUNCH_WINDOW) - await page.getByRole("button", { name: "Reply", exact: true }).click() - await parkMouse(page) - }, - }), -] diff --git a/packages/ui-parity/src/scenarios/chat.ts b/packages/ui-parity/src/scenarios/chat.ts deleted file mode 100644 index b5eccafa0..000000000 --- a/packages/ui-parity/src/scenarios/chat.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { Page } from "playwright" -import { richChat, richDataset } from "../fixtures/datasets/rich.ts" -import { chatOverlayScenarios, parkMouse } from "./chat-overlays.ts" -import { chat, org, type AreaModule, type Scenario } from "./types.ts" - -/** A scenario on the `rich` dataset (threads, embeds, attachments, bots, ...). */ -const rich = (scenario: Omit): Scenario => ({ - area: "chat", - dataset: "rich", - ...scenario, -}) - -const scrollTo = async (page: Page, text: string) => { - await page.getByText(text).first().scrollIntoViewIfNeeded() -} - -const richStates: ReadonlyArray = [ - rich({ - id: "chat-rich-channel", - title: "Channel with date separators, pins, a thread, custom emoji, a mention and a Discord-synced message", - path: richChat("launch"), - viewports: ["desktop", "mobile"], - themes: ["light", "dark"], - }), - rich({ - id: "chat-date-separators", - title: "Scrolled to the oldest day: grouped messages and date separators", - path: richChat("launch"), - steps: (page) => scrollTo(page, "Kicking off the launch channel"), - }), - rich({ - id: "chat-reply-preview", - title: "Reply with its quoted parent and a code block above", - path: richChat("launch"), - steps: (page) => scrollTo(page, "Rollout plan for the sync service"), - }), - rich({ - id: "chat-thread-panel", - title: "Thread panel open beside the channel", - path: richChat("launch"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: /3 replies/ }).click() - await page.getByText("CI freeze and release tagging are mine.").waitFor() - await parkMouse(page) - }, - }), - rich({ - id: "chat-thread-channel", - title: "Thread opened as its own channel, with the parent breadcrumb", - path: richChat("launch-thread"), - }), - rich({ - id: "chat-pinned-messages", - title: "Pinned messages panel", - path: richChat("launch"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: "View pinned messages" }).click() - await page.getByRole("dialog").waitFor() - }, - }), - rich({ - id: "chat-embed-github", - title: "GitHub bot: merged PR and push embeds", - path: richChat("github"), - }), - rich({ - id: "chat-embed-railway", - title: "Railway bot: crashed and deployed embeds", - path: richChat("deploys"), - themes: ["light", "dark"], - }), - rich({ - id: "chat-embed-openstatus", - title: "OpenStatus bot: down and recovered", - path: richChat("status"), - }), - rich({ - id: "chat-embed-rich-link", - title: "Rich link embed with author, thumbnail, image and footer", - path: richChat("links"), - }), - rich({ - id: "chat-attachments", - title: "Image grids, documents and a video", - path: richChat("media"), - viewports: ["desktop", "mobile"], - }), - rich({ - id: "chat-attachments-grid", - title: "Five-image grid with the +1 overlay", - path: richChat("media"), - steps: (page) => scrollTo(page, "Moodboard for the launch page"), - }), - rich({ - id: "chat-image-viewer", - title: "Image viewer opened from an attachment", - path: richChat("media"), - steps: async (page) => { - await page.getByRole("img", { name: "dashboard.png" }).click() - // The viewer has no dialog role; it renders a second copy of the image. - await page.getByRole("img", { name: "dashboard.png" }).nth(1).waitFor() - }, - }), - rich({ - id: "chat-long-message", - title: "Long message with a wide code line and an unbroken token", - path: richChat("long-reads"), - viewports: ["desktop", "mobile"], - // Taller than the mobile viewport, and the initial scroll lands at either end. Pin it to the - // start: scrolling up can't chain into the page, scrolling down sometimes moves the body 1px. - steps: async (page) => { - await page.getByText("Raw trace id for reference").hover() - await page.mouse.wheel(0, -10_000) - await parkMouse(page) - }, - }), - rich({ - id: "chat-join-banner", - title: "Public channel the user hasn't joined", - path: richChat("announcements"), - themes: ["light", "dark"], - }), - rich({ id: "chat-typing-indicator", title: "Two teammates typing", path: richChat("standup") }), - rich({ - id: "chat-files-tab-populated", - title: "Files tab with media and document rows", - path: `${richChat("media")}/files`, - themes: ["light", "dark"], - }), - rich({ id: "chat-files-media", title: "All media gallery", path: `${richChat("media")}/files/media` }), - // The chat index (`/$orgSlug/chat`) is covered by the home area (chat-index*). -] - -export const chatArea: AreaModule = { - datasets: [richDataset], - scenarios: [ - { - id: "chat-channel", - area: "chat", - title: "Channel with messages, reactions and markdown", - path: chat("general"), - viewports: ["desktop", "mobile"], - themes: ["light", "dark"], - }, - { id: "chat-channel-empty", area: "chat", title: "Channel with no messages", path: chat("random") }, - { id: "chat-private-channel", area: "chat", title: "Private channel", path: chat("leadership") }, - { id: "chat-dm", area: "chat", title: "Direct message", path: chat("dm-grace") }, - { - id: "chat-message-hover", - area: "chat", - title: "Hovered message shows its action toolbar", - path: chat("general"), - steps: async (page) => { - await page.getByText("Shipped the new onboarding copy").hover() - }, - }, - { - id: "chat-composer-focused", - area: "chat", - title: "Composer focused with draft text", - path: chat("general"), - steps: async (page) => { - // The composer is a combobox (mention autocomplete) with no accessible name yet. - await page.getByRole("combobox").last().click() - await page.keyboard.type("Draft reply with **bold** text") - }, - }, - { - id: "chat-files-tab", - area: "chat", - title: "Channel files tab", - path: `${chat("general")}/files`, - }, - ...richStates, - ...chatOverlayScenarios, - ], -} diff --git a/packages/ui-parity/src/scenarios/composer.ts b/packages/ui-parity/src/scenarios/composer.ts deleted file mode 100644 index e414b29d7..000000000 --- a/packages/ui-parity/src/scenarios/composer.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { AreaModule, Scenario } from "./types.ts" - -/** - * The channel composer on `/dev/gallery/composer` (legacy `SlateMessageComposer`, Foldkit - * ProseMirror Mount). The chat shell is not ported yet, so the composer is compared alone. - * The editor is a combobox with no accessible name (README, known gaps). - */ -const composer = (scenario: Omit): Scenario => ({ - ...scenario, - area: "chat", - path: "/dev/gallery/composer", -}) - -const editor = (page: Parameters>[0]) => page.getByRole("combobox") - -export const composerArea: AreaModule = { - scenarios: [ - composer({ id: "composer-empty", title: "Composer: empty with placeholder", themes: ["light", "dark"] }), - composer({ - id: "composer-focused", - title: "Composer: focused with draft text", - themes: ["light", "dark"], - steps: async (page) => { - await editor(page).click() - await page.keyboard.type("Draft reply with **bold** text") - }, - }), - composer({ - id: "composer-with-draft", - title: "Composer: multi-line draft with bold, italic, code and a mention", - themes: ["light", "dark"], - steps: async (page) => { - await editor(page).click() - await page.keyboard.type("Release notes are **ready** for review") - await page.keyboard.press("Shift+Enter") - await page.keyboard.type("Please check the _wording_ and the `sync` section") - await page.keyboard.press("Shift+Enter") - await page.keyboard.type("cc @gr") - await page.getByRole("option", { name: "Grace Hopper" }).waitFor() - await page.keyboard.press("Enter") - await page.keyboard.type(" thanks!") - }, - }), - composer({ - id: "composer-mention-autocomplete", - title: "Composer: mention autocomplete open", - themes: ["light", "dark"], - steps: async (page) => { - await editor(page).click() - await page.keyboard.type("Hey @g") - await page.getByRole("listbox").waitFor() - }, - }), - ], -} diff --git a/packages/ui-parity/src/scenarios/entry.ts b/packages/ui-parity/src/scenarios/entry.ts deleted file mode 100644 index 41f732a6c..000000000 --- a/packages/ui-parity/src/scenarios/entry.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { emptyDataset, emptyIds } from "../fixtures/datasets/empty.ts" -import { - multiOrgDataset, - publicOrganizationBySlug, - publicOrgSlug, - signedOutDataset, -} from "../fixtures/datasets/entry.ts" -import { onboardingDataset } from "../fixtures/datasets/onboarding.ts" -import type { AreaModule, Scenario } from "./types.ts" - -/** Signed-out and pre-org screens: root, sign-in/up, onboarding, select organization, join. */ - -const area = "entry" -const responsive = { viewports: ["desktop", "mobile"] } as const -const responsiveThemed = { ...responsive, themes: ["light", "dark"] } as const - -/** Creator-flow steps reachable through `?step=`; welcome is the bare `/onboarding`. */ -const onboardingSteps = [ - ["profile", "profileInfo", "Profile details"], - ["timezone", "timezoneSelection", "Timezone selection"], - ["theme", "themeSelection", "Theme and brand colour"], - ["use-cases", "useCases", "Use cases"], - ["role", "role", "Role"], - ["invite-team", "teamInvitation", "Invite your team"], -] as const - -const rootScenarios: Scenario[] = [ - { id: "root-signed-in", area, title: "Root redirects an onboarded member to their org", path: "/" }, - { - id: "root-signed-out", - area, - title: "Root redirects an anonymous visitor to sign-in", - path: "/", - dataset: "signed-out", - }, - { - id: "root-onboarding", - area, - title: "Root redirects a new user to onboarding", - path: "/", - dataset: "onboarding", - }, -] - -// Clerk's prebuilt components mount into an empty container in parity runs (see README Known gaps). -const authScenarios: Scenario[] = [ - { - id: "sign-in", - area, - title: "Sign-in page (Clerk component container)", - path: "/sign-in", - dataset: "signed-out", - ...responsiveThemed, - }, - { - id: "sign-up", - area, - title: "Sign-up page (Clerk component container)", - path: "/sign-up", - dataset: "signed-out", - ...responsiveThemed, - }, -] - -const onboardingScenarios: Scenario[] = [ - { - id: "onboarding-welcome", - area, - title: "Onboarding welcome, creating a workspace", - path: "/onboarding", - dataset: "onboarding", - ...responsiveThemed, - }, - { - id: "onboarding-welcome-continue", - area, - title: "Get Started advances to the profile step", - path: "/onboarding", - dataset: "onboarding", - steps: async (page) => { - await page.getByRole("button", { name: /Get Started/ }).click() - await page.getByRole("button", { name: /Back/ }).waitFor() - }, - }, - ...onboardingSteps.map( - ([id, step, title]): Scenario => ({ - id: `onboarding-${id}`, - area, - title: `Onboarding step: ${title}`, - path: `/onboarding?step=${step}`, - dataset: "onboarding", - ...responsive, - }), - ), - { - id: "onboarding-profile-invalid", - area, - title: "Profile step with an empty first name", - path: "/onboarding?step=profileInfo", - dataset: "onboarding", - steps: async (page) => { - await page.getByRole("textbox", { name: "First name" }).fill("") - }, - }, - { - id: "onboarding-invite-team-filled", - area, - title: "Invite step with a teammate's email entered", - path: "/onboarding?step=teamInvitation", - dataset: "onboarding", - steps: async (page) => { - await page.getByRole("textbox", { name: "Email 1" }).fill("grace@hazel.test") - await page.getByRole("button", { name: "Add another email" }).click() - }, - }, - { - id: "onboarding-setup-organization", - area, - title: "Organization setup (Clerk component container)", - path: "/onboarding/setup-organization", - dataset: "onboarding", - ...responsiveThemed, - }, -] - -const selectOrganizationScenarios: Scenario[] = [ - { - id: "select-organization", - area, - title: "Organization picker with three organizations", - path: "/select-organization", - dataset: "multi-org", - ...responsiveThemed, - }, - { - id: "select-organization-empty", - area, - title: "No organizations: create your workspace", - path: "/select-organization", - dataset: "onboarding", - ...responsive, - }, - { - id: "select-organization-unfinished", - area, - title: "Picking an organization without a slug opens its setup", - path: "/select-organization", - dataset: "multi-org", - steps: async (page) => { - await page.getByRole("button", { name: /Orbit Studio/ }).click() - await page.waitForURL(/\/onboarding\/setup-organization/) - }, - }, -] - -const joinScenarios: Scenario[] = [ - { - id: "join-signed-out", - area, - title: "Public invite seen by an anonymous visitor", - path: `/join/${publicOrgSlug}`, - dataset: "signed-out", - ...responsiveThemed, - }, - { - id: "join-signed-in", - area, - title: "Public invite seen by a signed-in user", - path: `/join/${publicOrgSlug}`, - ...responsive, - }, - { - id: "join-not-found", - area, - title: "Invalid or non-public invite link", - path: "/join/does-not-exist", - dataset: "signed-out", - ...responsive, - }, -] - -const emptyScenarios: Scenario[] = [ - { - id: "empty-org-home", - area, - title: "Home of a brand-new organization", - path: `/${emptyIds.orgSlug}`, - dataset: "empty", - ...responsiveThemed, - }, - { - id: "empty-chat", - area, - title: "Chat index with no channels", - path: `/${emptyIds.orgSlug}/chat`, - dataset: "empty", - ...responsive, - }, -] - -export const entryArea: AreaModule = { - scenarios: [ - ...rootScenarios, - ...authScenarios, - ...onboardingScenarios, - ...selectOrganizationScenarios, - ...joinScenarios, - ...emptyScenarios, - ], - datasets: [onboardingDataset, emptyDataset, signedOutDataset, multiOrgDataset], - rpc: () => ({ "organization.getBySlugPublic": publicOrganizationBySlug }), -} diff --git a/packages/ui-parity/src/scenarios/gallery-data.ts b/packages/ui-parity/src/scenarios/gallery-data.ts deleted file mode 100644 index dcb55c44e..000000000 --- a/packages/ui-parity/src/scenarios/gallery-data.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { Page } from "playwright" -import type { AreaModule, Scenario } from "./types.ts" - -/** - * Data-display UI kit primitives on `/dev/gallery/` (tabs, toggles, lists, tables, ...). - * Static states come from the entry; hover, focus-visible and selection changes come from steps. - */ -const gallery = ( - name: string, - scenario: Omit & { readonly query?: string }, -): Scenario => ({ - ...scenario, - area: "gallery", - path: `/dev/gallery/${name}${scenario.query ?? ""}`, - themes: ["light", "dark"], - fullPage: true, -}) - -const tab = (page: Page, name: string) => page.getByRole("tab", { name, exact: true }) - -const tabsScenarios = [ - gallery("tabs", { id: "gallery-tabs", title: "Tabs: horizontal, vertical, icons, long labels" }), - gallery("tabs", { - id: "gallery-tabs-hover", - title: "Tabs: hovered tab", - steps: async (page) => { - await tab(page, "Members").hover() - }, - }), - gallery("tabs", { - id: "gallery-tabs-focus-visible", - title: "Tabs: keyboard focus ring on the selected tab", - steps: async (page) => { - await page.keyboard.press("Tab") - }, - }), - gallery("tabs", { - id: "gallery-tabs-click", - title: "Tabs: selection changed by click", - steps: async (page) => { - await tab(page, "Integrations").click() - await page.mouse.move(0, 0) - }, - }), - gallery("tabs", { - id: "gallery-tabs-arrow-keys", - title: "Tabs: arrow keys move selection and skip disabled tabs", - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowRight") - await tab(page, "Security").click() - await page.mouse.move(0, 0) - await page.keyboard.press("ArrowDown") - }, - }), -] - -const toggleScenarios = [ - gallery("toggle", { id: "gallery-toggle", title: "Toggle: intents, sizes, selected, disabled" }), - gallery("toggle", { - id: "gallery-toggle-hover", - title: "Toggle: hovered", - steps: async (page) => { - await page.getByRole("button", { name: "Outline", exact: true }).hover() - }, - }), - gallery("toggle", { - id: "gallery-toggle-keyboard", - title: "Toggle: focus ring and Space toggles", - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("Tab") - await page.keyboard.press("Space") - }, - }), - gallery("toggle", { - id: "gallery-toggle-click", - title: "Toggle: click selects and deselects", - steps: async (page) => { - await page.getByRole("button", { name: "Plain", exact: true }).click() - await page.getByRole("button", { name: "Outline selected", exact: true }).click() - await page.mouse.move(0, 0) - }, - }), -] - -const toggleGroupScenarios = [ - gallery("toggle-group", { - id: "gallery-toggle-group", - title: "Toggle group: single, multiple, vertical", - }), - gallery("toggle-group", { - id: "gallery-toggle-group-hover", - title: "Toggle group: hovered item", - steps: async (page) => { - await page.getByRole("radio", { name: "Left", exact: true }).hover() - }, - }), - gallery("toggle-group", { - id: "gallery-toggle-group-click", - title: "Toggle group: click selection, single and multiple", - steps: async (page) => { - await page.getByRole("radio", { name: "Right", exact: true }).click() - await page.getByRole("button", { name: "Underline", exact: true }).click() - await page.getByRole("button", { name: "Bold", exact: true }).click() - await page.mouse.move(0, 0) - }, - }), - gallery("toggle-group", { - id: "gallery-toggle-group-arrow-keys", - title: "Toggle group: arrow keys move focus, Space selects", - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("Space") - }, - }), -] - -const staticScenarios = [ - gallery("separator", { id: "gallery-separator", title: "Separator: horizontal, vertical" }), - gallery("empty-state", { id: "gallery-empty-state", title: "Empty state: full, title only, long" }), - gallery("section-header", { - id: "gallery-section-header", - title: "Section header, label and footer", - }), - gallery("text", { id: "gallery-text", title: "Text, strong, code, keyboard" }), - gallery("loader", { - id: "gallery-loader", - title: "Loader: ring and spin variants", - // The spin variant rotates with SMIL, which the harness cannot freeze. - mask: (page) => [page.getByRole("progressbar", { name: /^Spinning/ })], - }), - gallery("progress-bar", { - id: "gallery-progress-bar", - title: "Progress bar: values, header, indeterminate", - }), - gallery("toolbar", { id: "gallery-toolbar", title: "Toolbar: horizontal, vertical" }), - gallery("toolbar", { - id: "gallery-toolbar-arrow-keys", - title: "Toolbar: arrow keys move focus", - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowRight") - }, - }), -] - -const table = (page: Page, name: string) => page.getByRole("grid", { name, exact: true }) - -const tableScenarios = [ - gallery("table", { id: "gallery-table", title: "Table: selectable, sortable, grid, striped, empty" }), - gallery("table", { - id: "gallery-table-click", - title: "Table: row selection by click", - steps: async (page) => { - await table(page, "Members") - .getByRole("row", { name: /Ada Lovelace/ }) - .click() - await page.mouse.move(0, 0) - }, - }), - gallery("table", { - id: "gallery-table-arrow-keys", - title: "Table: keyboard focus ring and arrow keys skip disabled rows", - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - }, - }), - gallery("table", { - id: "gallery-table-cell-keys", - title: "Table: cell focus by click, arrow keys move between cells", - steps: async (page) => { - await table(page, "Members").getByRole("gridcell", { name: "Owner" }).click() - await page.mouse.move(0, 0) - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowRight") - }, - }), - gallery("table", { - id: "gallery-table-sort", - title: "Table: sortable header hover and sort change", - steps: async (page) => { - const sortable = table(page, "Sortable members") - await sortable.getByRole("columnheader", { name: "Channels" }).click() - await sortable.getByRole("columnheader", { name: "Channels" }).click() - await sortable.getByRole("columnheader", { name: "Name" }).hover() - }, - }), -] - -const treeRow = (page: Page, name: string) => page.getByRole("row", { name, exact: false }) - -const treeScenarios = [ - gallery("tree", { id: "gallery-tree", title: "Tree: nested, expanded, disabled" }), - gallery("tree", { - id: "gallery-tree-click", - title: "Tree: focus by click, collapse and expand with the chevron", - steps: async (page) => { - await treeRow(page, "backend").click() - await treeRow(page, "Engineering").getByRole("button").first().click() - await treeRow(page, "Design").getByRole("button").first().click() - await page.mouse.move(0, 0) - }, - }), - gallery("tree", { - id: "gallery-tree-arrow-keys", - title: "Tree: arrow keys move focus, expand and collapse", - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowUp") - await page.keyboard.press("ArrowUp") - await page.keyboard.press("ArrowLeft") - }, - }), -] - -export const galleryDataArea: AreaModule = { - scenarios: [ - ...tabsScenarios, - ...toggleScenarios, - ...toggleGroupScenarios, - ...staticScenarios, - ...tableScenarios, - ...treeScenarios, - ], -} diff --git a/packages/ui-parity/src/scenarios/gallery-kit.ts b/packages/ui-parity/src/scenarios/gallery-kit.ts deleted file mode 100644 index 35f322818..000000000 --- a/packages/ui-parity/src/scenarios/gallery-kit.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { Page } from "playwright" -import type { Scenario, ViewportName } from "./types.ts" - -/** - * Kit follow-ups on `/dev/gallery/`: Toast, ListBox, calendars, Dropdown and friends. - * Spread into the overlays area, so registering them touches no shared file. - */ -const kit = ( - name: string, - id: string, - title: string, - options: { - readonly steps?: (page: Page) => Promise - readonly viewports?: ReadonlyArray - } = {}, -): Scenario => ({ - id: `gallery-${id}`, - title, - area: "gallery", - path: `/dev/gallery/${name}`, - themes: ["light", "dark"], - ...options, -}) - -const both: ReadonlyArray = ["desktop", "mobile"] - -const button = (page: Page, name: string) => page.getByRole("button", { name, exact: true }) - -/** Clicks a gallery button and waits for the toast it raises. */ -const raise = async (page: Page, label: string, text: string) => { - await button(page, label).click() - await page.getByText(text, { exact: true }).waitFor() -} - -const toastKinds: ReadonlyArray = [ - ["default", "Default", "Message copied"], - ["success", "Success", "Channel created"], - ["error", "Error", "Couldn't send message"], - ["info", "Info", "You're offline"], - ["warning", "Warning", "Storage almost full"], - ["description", "With description", "Invitation sent"], - ["action", "With action", "Message deleted"], - ["loading", "Loading", "Uploading attachment…"], - ["promise", "Promise", "Changes saved"], -] - -const raiseStack = async (page: Page) => { - await raise(page, "Success", "Channel created") - await raise(page, "With description", "Invitation sent") - await raise(page, "Error", "Couldn't send message") -} - -export const toastScenarios: ReadonlyArray = [ - kit("toast", "toast", "Toast: triggers, no toast"), - ...toastKinds.map(([id, label, text]) => - kit("toast", `toast-${id}`, `Toast: ${label.toLowerCase()}`, { - steps: (page) => raise(page, label, text), - }), - ), - kit("toast", "toast-stacked", "Toast: three toasts collapse into a stack", { - viewports: both, - steps: raiseStack, - }), - kit("toast", "toast-expanded", "Toast: hovering the stack expands it", { - viewports: both, - steps: async (page) => { - await raiseStack(page) - await page.getByText("Couldn't send message", { exact: true }).hover() - }, - }), - kit("toast", "toast-overflow", "Toast: a fourth toast hides the oldest", { - steps: async (page) => { - await raiseStack(page) - await raise(page, "Info", "You're offline") - }, - }), - kit("toast", "toast-action-dismiss", "Toast: the action dismisses its toast", { - steps: async (page) => { - await raise(page, "Success", "Channel created") - await raise(page, "With action", "Message deleted") - await button(page, "Undo").click() - await page.getByText("Message deleted", { exact: true }).waitFor({ state: "detached" }) - }, - }), -] - -const option = (page: Page, name: string) => page.getByRole("option", { name, exact: true }) - -/** Tabs until focus lands inside the list box with this label. */ -const tabInto = async (page: Page, label: string) => { - const list = page.getByRole("listbox", { name: label, exact: true }) - for (let presses = 0; presses < 10; presses++) { - await page.keyboard.press("Tab") - const inside = await list.evaluate((element) => element.contains(document.activeElement)) - if (inside) return - } - throw new Error(`tabInto: "${label}" never received focus`) -} - -export const listBoxScenarios: ReadonlyArray = [ - kit("list-box", "list-box", "ListBox: single, multiple with descriptions, sections"), - kit("list-box", "list-box-hover", "ListBox: hovered option", { - steps: (page) => option(page, "Dark").hover(), - }), - kit("list-box", "list-box-select", "ListBox: clicking selects an option", { - steps: (page) => option(page, "Light").click(), - }), - kit("list-box", "list-box-multiple", "ListBox: clicking toggles in multiple selection", { - steps: async (page) => { - await option(page, "Replies").click() - await option(page, "Reactions").click() - }, - }), - kit("list-box", "list-box-keyboard", "ListBox: Tab focuses the selected option, ArrowUp moves", { - steps: async (page) => { - await tabInto(page, "Theme") - await page.keyboard.press("ArrowUp") - }, - }), - kit("list-box", "list-box-keyboard-select", "ListBox: Space selects, ArrowDown skips disabled", { - steps: async (page) => { - await tabInto(page, "Notify me about") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press(" ") - }, - }), - kit("list-box", "list-box-sections-keyboard", "ListBox: arrows cross sections", { - steps: async (page) => { - await tabInto(page, "Jump to") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - }, - }), - kit("list-box", "list-box-typeahead", "ListBox: typeahead moves focus", { - steps: async (page) => { - await tabInto(page, "Jump to") - await page.keyboard.type("al") - }, - }), -] - -export const dropdownScenarios: ReadonlyArray = [ - kit("dropdown", "dropdown", "Dropdown: items, sections, separator, intents"), - kit("dropdown", "dropdown-hover", "Dropdown: hovered danger item", { - steps: (page) => option(page, "Delete").hover(), - }), - kit("dropdown", "dropdown-keyboard", "Dropdown: keyboard focus on an item with a shortcut", { - steps: async (page) => { - await tabInto(page, "Message actions") - await page.keyboard.press("ArrowDown") - }, - }), -] - -/** A calendar day cell; React Aria names them "Thursday, March 12, 2026" (with "Today, " etc.). */ -const day = (page: Page, date: string) => page.getByRole("button", { name: new RegExp(`\\b${date}\\b`) }) - -/** Tabs until a calendar day cell has focus. */ -const tabToDay = async (page: Page, date: string) => { - for (let presses = 0; presses < 15; presses++) { - await page.keyboard.press("Tab") - const focused = await day(page, date) - .first() - .evaluate((element) => element === document.activeElement) - if (focused) return - } - throw new Error(`tabToDay: "${date}" never received focus`) -} - -const eventDate = (page: Page) => page.getByRole("application", { name: /^Event date/ }) - -export const calendarScenarios: ReadonlyArray = [ - kit("calendar", "calendar", "Calendar: selected date, today marker, minimum date"), - kit("calendar", "calendar-hover", "Calendar: hovered day", { - steps: (page) => day(page, "March 20, 2026").first().hover(), - }), - kit("calendar", "calendar-select", "Calendar: clicking a day selects it", { - steps: (page) => day(page, "March 25, 2026").first().click(), - }), - kit("calendar", "calendar-keyboard", "Calendar: Tab focuses the selected day, arrows move", { - steps: async (page) => { - await tabToDay(page, "March 18, 2026") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowDown") - }, - }), - kit("calendar", "calendar-keyboard-month", "Calendar: arrows cross into the next month, Enter selects", { - steps: async (page) => { - await tabToDay(page, "March 18, 2026") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("Enter") - }, - }), - kit("calendar", "calendar-next", "Calendar: the next button shows the next month", { - steps: async (page) => { - await eventDate(page).getByRole("button", { name: "Next", exact: true }).first().click() - await page.getByRole("application", { name: "Event date, April 2026" }).waitFor() - }, - }), -] - -export const rangeCalendarScenarios: ReadonlyArray = [ - kit("range-calendar", "range-calendar", "RangeCalendar: selected range"), - kit("range-calendar", "range-calendar-anchor", "RangeCalendar: first click anchors, hover highlights", { - steps: async (page) => { - await day(page, "March 16, 2026").click() - await day(page, "March 20, 2026").hover() - }, - }), - kit("range-calendar", "range-calendar-select", "RangeCalendar: two clicks select a new range", { - steps: async (page) => { - await day(page, "March 16, 2026").click() - await day(page, "March 19, 2026").click() - }, - }), - kit("range-calendar", "range-calendar-hover", "RangeCalendar: hovered day inside the range", { - steps: (page) => day(page, "March 10, 2026").hover(), - }), -] - -export const datePickerScenarios: ReadonlyArray = [ - kit("date-picker", "date-picker", "DatePicker: with a value, labelled and empty"), - kit("date-picker", "date-picker-open", "DatePicker: the calendar button opens the calendar", { - steps: async (page) => { - await page - .getByRole("button", { name: /^Calendar/ }) - .first() - .click() - await page.getByRole("dialog").waitFor() - }, - }), - kit("date-picker", "date-picker-choose", "DatePicker: choosing a day fills the field and closes", { - steps: async (page) => { - await page - .getByRole("button", { name: /^Calendar/ }) - .first() - .click() - await page.getByRole("dialog").waitFor() - await day(page, "March 24, 2026").click() - await page.getByRole("dialog").waitFor({ state: "detached" }) - }, - }), -] - -const checkbox = (page: Page, name: string) => page.getByRole("checkbox", { name, exact: true }) - -export const tableSelectionScenarios: ReadonlyArray = [ - kit("table-selection", "table-selection", "Table: checkbox selection, one selected, one disabled"), - kit("table-selection", "table-selection-row", "Table: clicking a row toggles it", { - steps: (page) => page.getByRole("row", { name: /Alan Turing/ }).click(), - }), - kit("table-selection", "table-selection-all", "Table: the header checkbox selects every row", { - steps: (page) => checkbox(page, "Select All").click({ force: true }), - }), - kit("table-selection", "table-selection-hover", "Table: hovered selectable row", { - steps: (page) => page.getByRole("row", { name: /Ada Lovelace/ }).hover(), - }), - kit("table-selection", "table-selection-keyboard", "Table: Space toggles the focused row", { - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowDown") - await page.keyboard.press(" ") - }, - }), -] - -export const galleryKitScenarios: ReadonlyArray = [ - ...tableSelectionScenarios, - ...calendarScenarios, - ...rangeCalendarScenarios, - ...datePickerScenarios, - ...toastScenarios, - ...listBoxScenarios, - ...dropdownScenarios, -] diff --git a/packages/ui-parity/src/scenarios/gallery-overlays.ts b/packages/ui-parity/src/scenarios/gallery-overlays.ts deleted file mode 100644 index 44e25e3f5..000000000 --- a/packages/ui-parity/src/scenarios/gallery-overlays.ts +++ /dev/null @@ -1,503 +0,0 @@ -import type { Page } from "playwright" -import { galleryKitScenarios } from "./gallery-kit.ts" -import type { AreaModule, Scenario, ViewportName } from "./types.ts" - -/** - * Overlay primitives on `/dev/gallery/`: menus, dialogs, tooltips, selects, popovers, sheets. - * Open states come from steps, so each one also exercises the trigger, focus and keyboard behavior. - */ -const overlay = ( - name: string, - id: string, - title: string, - options: { - readonly steps?: (page: Page) => Promise - readonly viewports?: ReadonlyArray - } = {}, -): Scenario => ({ - id: `gallery-${id}`, - title, - area: "gallery", - path: `/dev/gallery/${name}`, - themes: ["light", "dark"], - ...options, -}) - -const both: ReadonlyArray = ["desktop", "mobile"] - -const button = (page: Page, name: string) => page.getByRole("button", { name, exact: true }) -const menuItem = (page: Page, name: string) => page.getByRole("menuitem", { name }) -/** React Aria shows hover tooltips only once the last interaction was a pointer press. */ -const hoverAfterPointerPress = async (page: Page, name: string) => { - await page.mouse.click(1200, 800) - await button(page, name).hover() -} -const focusByTab = async (page: Page, name: string) => { - for (let presses = 0; presses < 10; presses++) { - await page.keyboard.press("Tab") - const focused = await button(page, name).evaluate((element) => element === document.activeElement) - if (focused) return - } - throw new Error(`focusByTab: "${name}" never received focus`) -} - -export const galleryOverlaysArea: AreaModule = { - scenarios: [ - // MENU - overlay("menu", "menu", "Menu: closed triggers"), - overlay("menu", "menu-open", "Menu: opened by click", { - steps: async (page) => { - await button(page, "Actions").click() - await page.getByRole("menu").waitFor() - }, - }), - overlay("menu", "menu-item-hover", "Menu: hovered item", { - steps: async (page) => { - await button(page, "Actions").click() - await menuItem(page, "Favorite").hover() - }, - }), - overlay("menu", "menu-keyboard", "Menu: opened with Enter, second item focused", { - steps: async (page) => { - await focusByTab(page, "Actions") - await page.keyboard.press("Enter") - await page.getByRole("menu").waitFor() - await page.keyboard.press("ArrowDown") - }, - }), - overlay("menu", "menu-arrow-up", "Menu: opened with ArrowUp, last item focused", { - steps: async (page) => { - await focusByTab(page, "Actions") - await page.keyboard.press("ArrowUp") - await page.getByRole("menu").waitFor() - }, - }), - overlay("menu", "menu-typeahead", "Menu: typeahead moves focus", { - steps: async (page) => { - await focusByTab(page, "Actions") - await page.keyboard.press("ArrowDown") - await page.getByRole("menu").waitFor() - await page.keyboard.type("le") - }, - }), - overlay("menu", "menu-submenu", "Menu: submenu opened by hover", { - steps: async (page) => { - await button(page, "Actions").click() - await menuItem(page, "Move to section").hover() - await page.getByRole("menu").nth(1).waitFor() - }, - }), - overlay("menu", "menu-submenu-keyboard", "Menu: submenu opened with ArrowRight", { - steps: async (page) => { - await focusByTab(page, "Actions") - await page.keyboard.press("Enter") - await page.getByRole("menu").waitFor() - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowRight") - await page.getByRole("menu").nth(1).waitFor() - }, - }), - overlay("menu", "menu-escape", "Menu: Escape closes and returns focus", { - steps: async (page) => { - await focusByTab(page, "Actions") - await page.keyboard.press("Enter") - await page.getByRole("menu").waitFor() - await page.keyboard.press("Escape") - }, - }), - overlay("menu", "menu-outside", "Menu: outside click closes", { - steps: async (page) => { - await button(page, "Actions").click() - await page.getByRole("menu").waitFor() - await page.mouse.click(1000, 600) - }, - }), - overlay("menu", "menu-selection", "Menu: single selection with a checked item", { - steps: async (page) => { - await button(page, "Density").click() - await page.getByRole("menu").waitFor() - }, - }), - overlay("menu", "menu-trigger", "Menu: icon trigger, right top placement", { - steps: async (page) => { - await button(page, "More").click() - await page.getByRole("menu").waitFor() - }, - }), - // DIALOG - overlay("dialog", "dialog", "Dialog: closed triggers"), - overlay("dialog", "dialog-open", "Dialog: opened by click", { - viewports: both, - steps: async (page) => { - await button(page, "Open dialog").click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay("dialog", "dialog-alert", "Dialog: alert dialog opened with the keyboard", { - viewports: both, - steps: async (page) => { - await focusByTab(page, "Delete channel") - await page.keyboard.press("Enter") - await page.getByRole("alertdialog").waitFor() - }, - }), - overlay("dialog", "dialog-escape", "Dialog: Escape closes and returns focus", { - steps: async (page) => { - await focusByTab(page, "Open dialog") - await page.keyboard.press("Enter") - await page.getByRole("dialog").waitFor() - await page.keyboard.press("Escape") - }, - }), - overlay("dialog", "dialog-tab", "Dialog: Tab moves focus inside the dialog and wraps", { - steps: async (page) => { - await button(page, "Open dialog").click() - await page.getByRole("dialog").waitFor() - for (const _ of [1, 2, 3, 4]) await page.keyboard.press("Tab") - }, - }), - overlay("dialog", "dialog-outside", "Dialog: backdrop click closes", { - steps: async (page) => { - await button(page, "Open dialog").click() - await page.getByRole("dialog").waitFor() - await page.mouse.click(20, 880) - }, - }), - overlay("dialog", "dialog-close-icon", "Dialog: the close icon closes", { - steps: async (page) => { - await button(page, "Open dialog").click() - await page.getByRole("button", { name: "Close" }).click() - }, - }), - // TOOLTIP - overlay("tooltip", "tooltip", "Tooltip: closed triggers"), - overlay("tooltip", "tooltip-hover", "Tooltip: shown by hover", { - steps: async (page) => { - await hoverAfterPointerPress(page, "Top") - await page.getByRole("tooltip").waitFor() - }, - }), - overlay("tooltip", "tooltip-focus", "Tooltip: shown by keyboard focus", { - steps: async (page) => { - await focusByTab(page, "Rename thread") - await page.getByRole("tooltip").waitFor() - }, - }), - overlay("tooltip", "tooltip-bottom", "Tooltip: bottom placement", { - steps: async (page) => { - await hoverAfterPointerPress(page, "Bottom") - await page.getByRole("tooltip").waitFor() - }, - }), - overlay("tooltip", "tooltip-right", "Tooltip: right placement", { - steps: async (page) => { - await hoverAfterPointerPress(page, "Right") - await page.getByRole("tooltip").waitFor() - }, - }), - overlay("tooltip", "tooltip-inverse", "Tooltip: inverse", { - steps: async (page) => { - await hoverAfterPointerPress(page, "Inverse") - await page.getByRole("tooltip").waitFor() - }, - }), - overlay("tooltip", "tooltip-no-arrow", "Tooltip: without an arrow", { - steps: async (page) => { - await hoverAfterPointerPress(page, "No arrow") - await page.getByRole("tooltip").waitFor() - }, - }), - // SELECT - overlay("select", "select", "Select: selected, placeholder and disabled"), - overlay("select", "select-open", "Select: opened by click on a selected value", { - steps: async (page) => { - await page.getByRole("button", { name: /Clear after/ }).click() - await page.getByRole("listbox").waitFor() - }, - }), - overlay("select", "select-keyboard", "Select: opened with ArrowDown, option hovered by keyboard", { - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowDown") - await page.getByRole("listbox").waitFor() - await page.keyboard.press("ArrowDown") - }, - }), - overlay("select", "select-choose", "Select: choosing an option updates the value", { - steps: async (page) => { - await page.getByRole("button", { name: /Reminder/ }).click() - await page.getByRole("option", { name: "Today" }).click() - }, - }), - overlay("select", "select-option-hover", "Select: hovered option", { - steps: async (page) => { - await page.getByRole("button", { name: /Reminder/ }).click() - await page.getByRole("option", { name: "30 minutes" }).hover() - }, - }), - // POPOVER - overlay("popover", "popover", "Popover: closed triggers"), - overlay("popover", "popover-open", "Popover: opened by click", { - steps: async (page) => { - await button(page, "Details").click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay("popover", "popover-arrow", "Popover: arrow, right placement", { - steps: async (page) => { - await button(page, "With arrow").click() - await page.getByRole("dialog").waitFor() - }, - }), - // SHEET - overlay("sheet", "sheet", "Sheet: closed triggers"), - overlay("sheet", "sheet-open", "Sheet: right, floating", { - viewports: both, - steps: async (page) => { - await button(page, "Open sheet").click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay("sheet", "sheet-left", "Sheet: left, docked", { - viewports: both, - steps: async (page) => { - await button(page, "Open left sheet").click() - await page.getByRole("dialog").waitFor() - }, - }), - // CONTEXT MENU - overlay("context-menu", "context-menu", "Context menu: closed trigger"), - overlay("context-menu", "context-menu-open", "Context menu: opened at the pointer", { - steps: async (page) => { - await page - .getByText("Right-click this message") - .click({ button: "right", position: { x: 60, y: 40 } }) - await page.getByRole("menu").waitFor() - }, - }), - overlay("context-menu", "context-menu-hover", "Context menu: hovered danger item", { - steps: async (page) => { - await page - .getByText("Right-click this message") - .click({ button: "right", position: { x: 60, y: 40 } }) - await menuItem(page, "Delete message").hover() - }, - }), - overlay( - "context-menu", - "context-menu-keyboard", - "Context menu: arrow keys are ignored until an item has focus, as in React Aria", - { - steps: async (page) => { - await page - .getByText("Right-click this message") - .click({ button: "right", position: { x: 60, y: 40 } }) - await page.getByRole("menu").waitFor() - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - }, - }, - ), - overlay("context-menu", "context-menu-escape", "Context menu: Escape closes", { - steps: async (page) => { - await page - .getByText("Right-click this message") - .click({ button: "right", position: { x: 60, y: 40 } }) - await page.getByRole("menu").waitFor() - await page.keyboard.press("Escape") - }, - }), - // COMBO BOX - overlay("combo-box", "combo-box", "Combo box: empty and selected"), - overlay("combo-box", "combo-box-type", "Combo box: typing filters and opens the list", { - steps: async (page) => { - await page.getByRole("combobox", { name: "Channel" }).fill("de") - await page.getByRole("listbox").waitFor() - }, - }), - overlay( - "combo-box", - "combo-box-arrow", - "Combo box: ArrowDown opens all items and moves the virtual focus", - { - steps: async (page) => { - await page.getByRole("combobox", { name: "Channel" }).focus() - await page.keyboard.press("ArrowDown") - await page.getByRole("listbox").waitFor() - await page.keyboard.press("ArrowDown") - }, - }, - ), - overlay("combo-box", "combo-box-button", "Combo box: the chevron button shows every item", { - steps: async (page) => { - await page.getByRole("button", { name: /Selected/ }).click() - await page.getByRole("listbox").waitFor() - }, - }), - overlay("combo-box", "combo-box-choose", "Combo box: choosing an item fills the input", { - steps: async (page) => { - await page.getByRole("combobox", { name: "Channel" }).fill("eng") - await page.getByRole("option", { name: "Engineering" }).click() - }, - }), - overlay("combo-box", "combo-box-hover", "Combo box: hovered option", { - steps: async (page) => { - await page.getByRole("combobox", { name: "Channel" }).fill("e") - await page.getByRole("option", { name: "Design" }).hover() - }, - }), - overlay( - "combo-box", - "combo-box-escape", - "Combo box: Escape closes and reverts the text to the selection", - { - steps: async (page) => { - await page.getByRole("combobox", { name: "Channel" }).fill("ra") - await page.getByRole("listbox").waitFor() - await page.keyboard.press("Escape") - }, - }, - ), - // TIMEZONE SELECT - overlay("timezone-select", "timezone-select", "Timezone select: selected and empty"), - overlay("timezone-select", "timezone-select-type", "Timezone select: typing filters the zones", { - steps: async (page) => { - await page.getByRole("combobox").nth(1).fill("new") - await page.getByRole("listbox").waitFor() - }, - }), - overlay( - "timezone-select", - "timezone-select-arrow", - "Timezone select: ArrowDown opens the list at the selected zone", - { - steps: async (page) => { - await page.getByRole("combobox").first().focus() - await page.keyboard.press("ArrowDown") - await page.getByRole("listbox").waitFor() - }, - }, - ), - // COMMAND MENU - overlay("command-menu", "command-menu", "Command menu: closed"), - overlay( - "command-menu", - "command-menu-escape", - "Command menu: Escape clears the search, then closes", - { - steps: async (page) => { - await button(page, "Open command menu").click() - await page.getByRole("searchbox").fill("set") - await page.keyboard.press("Escape") - await page.keyboard.press("Escape") - }, - }, - ), - overlay("command-menu", "command-menu-open", "Command menu: opened", { - viewports: both, - steps: async (page) => { - await button(page, "Open command menu").click() - await page.getByRole("menu").waitFor() - }, - }), - overlay("command-menu", "command-menu-filter", "Command menu: typing filters the items", { - steps: async (page) => { - await button(page, "Open command menu").click() - await page.getByRole("searchbox").fill("chan") - }, - }), - overlay("command-menu", "command-menu-empty", "Command menu: no results", { - steps: async (page) => { - await button(page, "Open command menu").click() - await page.getByRole("searchbox").fill("zzz") - }, - }), - overlay("command-menu", "command-menu-arrow", "Command menu: arrow keys move the virtual focus", { - steps: async (page) => { - await button(page, "Open command menu").click() - await page.getByRole("menu").waitFor() - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - }, - }), - overlay("command-menu", "command-menu-hover", "Command menu: hovered item", { - steps: async (page) => { - await button(page, "Open command menu").click() - await menuItem(page, "Settings").hover() - }, - }), - // COMMAND MENU FORM - overlay("command-menu-form", "command-menu-form-open", "Command menu form: opened", { - viewports: both, - steps: async (page) => { - await button(page, "Create channel").click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay( - "command-menu-form", - "command-menu-form-input", - "Command menu form: typing in the focused input", - { - steps: async (page) => { - await button(page, "Create channel").click() - await page.getByRole("textbox", { name: "Name" }).fill("design-reviews") - }, - }, - ), - overlay( - "command-menu-form", - "command-menu-form-toggle", - "Command menu form: choosing the second option", - { - steps: async (page) => { - await button(page, "Create channel").click() - await page.getByRole("radio", { name: "Private" }).check({ force: true }) - }, - }, - ), - overlay("command-menu-form", "command-menu-form-escape", "Command menu form: Escape closes", { - steps: async (page) => { - await button(page, "Create channel").click() - await page.getByRole("dialog").waitFor() - await page.keyboard.press("Escape") - }, - }), - overlay("command-menu-form", "command-menu-form-back", "Command menu form: the back button closes", { - steps: async (page) => { - await button(page, "Create channel").click() - await page.getByRole("dialog").getByRole("button").first().click() - }, - }), - // MODAL - overlay("modal", "modal", "Modal: closed triggers"), - overlay("modal", "modal-small", "Modal: small", { - viewports: both, - steps: async (page) => { - await button(page, "Small").click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay("modal", "modal-extra-large", "Modal: extra large", { - steps: async (page) => { - await button(page, "Extra large").click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay("modal", "modal-fullscreen", "Modal: fullscreen", { - viewports: both, - steps: async (page) => { - await button(page, "Fullscreen").click() - await page.getByRole("dialog").waitFor() - }, - }), - overlay("modal", "modal-blurred", "Modal: blurred backdrop, no close icon", { - steps: async (page) => { - await button(page, "Blurred").click() - await page.getByRole("dialog").waitFor() - }, - }), - ...galleryKitScenarios, - ], -} diff --git a/packages/ui-parity/src/scenarios/gallery.ts b/packages/ui-parity/src/scenarios/gallery.ts deleted file mode 100644 index a644e7976..000000000 --- a/packages/ui-parity/src/scenarios/gallery.ts +++ /dev/null @@ -1,523 +0,0 @@ -import type { AreaModule, Scenario } from "./types.ts" - -/** - * UI kit primitives on `/dev/gallery/` (legacy `src/dev-gallery/entries`, Foldkit - * `src/gallery/entries`). Interactive states come from steps, so they also exercise behavior. - */ -const gallery = (name: string, scenario: Omit & { readonly query?: string }) => ({ - ...scenario, - area: "gallery", - path: `/dev/gallery/${name}${scenario.query ?? ""}`, -}) - -export const galleryArea: AreaModule = { - scenarios: [ - gallery("button", { - id: "gallery-button", - title: "Button: intents, sizes, circle", - themes: ["light", "dark"], - }), - gallery("button", { - id: "gallery-button-hover", - title: "Button: hovered primary", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: "primary", exact: true }).hover() - }, - }), - gallery("button", { - id: "gallery-button-focus-visible", - title: "Button: keyboard focus ring", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - }, - }), - gallery("button", { - id: "gallery-button-pressed", - title: "Button: pointer held down on secondary", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: "secondary", exact: true }).hover() - await page.mouse.down() - }, - }), - gallery("button", { - id: "gallery-button-clicked", - title: "Button: focused by pointer (no ring), pointer moved away", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: "outline", exact: true }).click() - await page.mouse.move(0, 0) - }, - }), - gallery("button", { - id: "gallery-button-keyboard-pressed", - title: "Button: Space held on the focused button", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.down(" ") - }, - }), - gallery("button", { - id: "gallery-button-pending-hover", - title: "Button: hovering a pending button shows no hover state", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: /Saving/ }).hover() - }, - }), - gallery("link", { - id: "gallery-link", - title: "Link: href, no href, disabled", - themes: ["light", "dark"], - }), - gallery("link", { - id: "gallery-link-hover", - title: "Link: hovered", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("link", { name: "Documentation" }).hover() - }, - }), - gallery("link", { - id: "gallery-link-focus-visible", - title: "Link: keyboard focus ring on the second link", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("Tab") - }, - }), - gallery("link", { - id: "gallery-link-pressed", - title: "Link: pointer held on a link without href", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("link", { name: "Pressable text" }).hover() - await page.mouse.down() - }, - }), - gallery("input", { - id: "gallery-input", - title: "Input: plain states and groups", - themes: ["light", "dark"], - }), - gallery("input", { - id: "gallery-input-hover", - title: "Input: hovered", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Name" }).hover() - }, - }), - gallery("input", { - id: "gallery-input-typed", - title: "Input: focused by click, text typed", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Name" }).click() - await page.keyboard.type("Grace Hopper") - await page.mouse.move(0, 0) - }, - }), - gallery("input", { - id: "gallery-input-invalid-focus", - title: "Input: invalid input focused by keyboard", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Filled" }).focus() - await page.keyboard.press("Tab") - }, - }), - gallery("text-field", { - id: "gallery-text-field", - title: "Text field: label, description, invalid, disabled, required", - themes: ["light", "dark"], - }), - gallery("text-field", { - id: "gallery-text-field-focus", - title: "Text field: first field focused by keyboard", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - }, - }), - gallery("text-field", { - id: "gallery-text-field-typed", - title: "Text field: text typed into the invalid field after clearing it", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Invalid" }).fill("") - await page.getByRole("textbox", { name: "Invalid" }).pressSequentially("ada@hazel.sh") - }, - }), - gallery("text-field", { - id: "gallery-text-field-label-click", - title: "Text field: clicking a label focuses its input", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Username", { exact: true }).click() - }, - }), - gallery("textarea", { id: "gallery-textarea", title: "Textarea: states", themes: ["light", "dark"] }), - gallery("textarea", { - id: "gallery-textarea-typed", - title: "Textarea: multi-line text grows the field", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Message" }).click() - await page.keyboard.type("First line") - await page.keyboard.press("Enter") - await page.keyboard.type("Second line") - await page.keyboard.press("Enter") - await page.keyboard.type("Third line") - }, - }), - gallery("textarea", { - id: "gallery-textarea-hover", - title: "Textarea: hovered", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Notes" }).hover() - }, - }), - gallery("field", { - id: "gallery-field", - title: "Field: label, description, errors, fieldset", - themes: ["light", "dark"], - }), - gallery("search-field", { - id: "gallery-search-field", - title: "Search field: empty, filled, disabled, labelled", - themes: ["light", "dark"], - }), - gallery("search-field", { - id: "gallery-search-field-typed", - title: "Search field: typing reveals the clear button", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("searchbox", { name: "Search messages" }).click() - await page.keyboard.type("standup") - }, - }), - gallery("search-field", { - id: "gallery-search-field-cleared", - title: "Search field: clear button empties the field", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: "Clear search" }).first().click() - }, - }), - gallery("search-field", { - id: "gallery-search-field-escape", - title: "Search field: Escape clears the focused field", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("searchbox", { name: "Search channels" }).focus() - await page.keyboard.press("Escape") - }, - }), - gallery("search-field", { - id: "gallery-search-field-clear-hover", - title: "Search field: hovered clear button", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: "Clear search" }).first().hover() - }, - }), - gallery("checkbox", { - id: "gallery-checkbox", - title: "Checkbox: states, description, groups", - themes: ["light", "dark"], - }), - gallery("checkbox", { - id: "gallery-checkbox-clicked", - title: "Checkbox: clicking checks it (no focus ring)", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Unchecked", { exact: true }).click() - await page.getByText("Reactions", { exact: true }).click() - await page.mouse.move(0, 0) - }, - }), - gallery("checkbox", { - id: "gallery-checkbox-keyboard", - title: "Checkbox: Tab then Space checks the first box with a focus ring", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press(" ") - }, - }), - gallery("checkbox", { - id: "gallery-checkbox-hover", - title: "Checkbox: hovered", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Invalid", { exact: true }).hover() - }, - }), - gallery("radio", { - id: "gallery-radio", - title: "Radio: groups and states", - themes: ["light", "dark"], - }), - gallery("radio", { - id: "gallery-radio-clicked", - title: "Radio: clicking selects an option", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Compact", { exact: true }).click() - await page.getByText("Dark", { exact: true }).click() - await page.mouse.move(0, 0) - }, - }), - gallery("radio", { - id: "gallery-radio-keyboard", - title: "Radio: Tab focuses the selected option, ArrowDown moves the selection", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowDown") - }, - }), - gallery("radio", { - id: "gallery-radio-hover", - title: "Radio: hovered", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Spacious", { exact: true }).hover() - }, - }), - gallery("switch", { - id: "gallery-switch", - title: "Switch: off, on, disabled, description", - themes: ["light", "dark"], - }), - gallery("switch", { - id: "gallery-switch-clicked", - title: "Switch: clicking turns it on", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Off", { exact: true }).click() - await page.mouse.move(0, 0) - }, - }), - gallery("switch", { - id: "gallery-switch-keyboard", - title: "Switch: Tab then Space toggles with a focus ring", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("Tab") - await page.keyboard.press(" ") - }, - }), - gallery("switch", { - id: "gallery-switch-hover", - title: "Switch: hovered", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Desktop notifications", { exact: true }).hover() - }, - }), - gallery("slider", { - id: "gallery-slider", - title: "Slider: single, range, disabled, vertical", - themes: ["light", "dark"], - }), - gallery("slider", { - id: "gallery-slider-keyboard", - title: "Slider: Tab to the thumb, ArrowRight three times", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowRight") - }, - }), - gallery("slider", { - id: "gallery-slider-range-keyboard", - title: "Slider: range thumb sent to its bound with End, then PageDown", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("slider", { name: "Price range" }).focus() - await page.keyboard.press("End") - await page.keyboard.press("PageDown") - }, - }), - gallery("slider", { - id: "gallery-slider-hover", - title: "Slider: hovered thumb", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("slider", { name: "Volume" }).hover({ force: true }) - }, - }), - gallery("slider", { - id: "gallery-slider-track-press", - title: "Slider: pressing the track at 75% moves the thumb there and focuses it", - themes: ["light", "dark"], - steps: async (page) => { - const group = await page.getByRole("group", { name: "Volume" }).boundingBox() - const thumb = await page.getByRole("slider", { name: "Volume" }).boundingBox() - if (!group || !thumb) throw new Error("slider not laid out") - await page.mouse.click(group.x + group.width * 0.75, thumb.y + thumb.height / 2) - }, - }), - gallery("slider", { - id: "gallery-slider-dragging", - title: "Slider: thumb mid-drag from 40% to 62%", - themes: ["light", "dark"], - steps: async (page) => { - const group = await page.getByRole("group", { name: "Volume" }).boundingBox() - const thumb = await page.getByRole("slider", { name: "Volume" }).boundingBox() - if (!group || !thumb) throw new Error("slider not laid out") - const y = thumb.y + thumb.height / 2 - await page.mouse.move(group.x + group.width * 0.4, y) - await page.mouse.down() - await page.mouse.move(group.x + group.width * 0.62, y, { steps: 5 }) - }, - }), - gallery("field-validation-state", { - id: "gallery-field-validation-state", - title: "Field validation state: idle, validating, valid, invalid", - themes: ["light", "dark"], - }), - gallery("form-error-summary", { - id: "gallery-form-error-summary", - title: "Form error summary: default and custom titles, empty", - themes: ["light", "dark"], - }), - gallery("choice-box", { - id: "gallery-choice-box", - title: "Choice box: grid, stack, multiple", - themes: ["light", "dark"], - }), - gallery("choice-box", { - id: "gallery-choice-box-clicked", - title: "Choice box: clicking selects an item in each list", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Just me", { exact: true }).click() - await page.getByText("Free", { exact: true }).click() - await page.getByText("Random", { exact: true }).click() - await page.mouse.move(0, 0) - }, - }), - gallery("choice-box", { - id: "gallery-choice-box-keyboard", - title: "Choice box: Tab into the grid, ArrowRight moves focus, Space selects", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowRight") - await page.keyboard.press(" ") - }, - }), - gallery("choice-box", { - id: "gallery-choice-box-stack-keyboard", - title: "Choice box: clicking the selected plan deselects it, ArrowUp moves focus", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Pro", { exact: true }).click() - await page.keyboard.press("ArrowUp") - }, - }), - gallery("choice-box", { - id: "gallery-choice-box-hover", - title: "Choice box: hovered item", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Growing company", { exact: true }).hover() - }, - }), - gallery("date-field", { - id: "gallery-date-field", - title: "Date field: empty, filled, disabled, invalid", - themes: ["light", "dark"], - }), - gallery("date-field", { - id: "gallery-date-field-focus", - title: "Date field: Tab focuses the month segment", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - }, - }), - gallery("date-field", { - id: "gallery-date-field-typed", - title: "Date field: digits typed into the empty field fill every segment", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.type("372026") - }, - }), - gallery("date-field", { - id: "gallery-date-field-arrows", - title: "Date field: ArrowUp/ArrowDown step segments, ArrowRight moves between them", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("spinbutton", { name: /month, Due date/ }).focus() - await page.keyboard.press("ArrowUp") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("Backspace") - }, - }), - gallery("time-field", { - id: "gallery-time-field", - title: "Time field: filled, empty, disabled", - themes: ["light", "dark"], - }), - gallery("time-field", { - id: "gallery-time-field-arrows", - title: "Time field: hour and minute stepped, day period toggled", - themes: ["light", "dark"], - steps: async (page) => { - await page.keyboard.press("Tab") - await page.keyboard.press("ArrowUp") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowDown") - await page.keyboard.press("ArrowRight") - await page.keyboard.press("ArrowUp") - }, - }), - gallery("time-field", { - id: "gallery-time-field-typed", - title: "Time field: digits and a day period typed into the empty field", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("spinbutton", { name: /hour, Reminder/ }).focus() - await page.keyboard.type("945p") - }, - }), - gallery("input-otp", { - id: "gallery-input-otp", - title: "Input OTP: empty, prefilled, disabled", - themes: ["light", "dark"], - }), - gallery("input-otp", { - id: "gallery-input-otp-focus", - title: "Input OTP: focused, first slot active with the caret", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Verification code" }).focus() - }, - }), - gallery("input-otp", { - id: "gallery-input-otp-typed", - title: "Input OTP: four digits typed", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("textbox", { name: "Verification code" }).focus() - await page.keyboard.type("4821") - }, - }), - ], -} diff --git a/packages/ui-parity/src/scenarios/home.ts b/packages/ui-parity/src/scenarios/home.ts deleted file mode 100644 index 0526f0ac0..000000000 --- a/packages/ui-parity/src/scenarios/home.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { defaultIds } from "../fixtures/datasets/default.ts" -import { personalIds } from "../fixtures/datasets/personal.ts" -import { org, type AreaModule } from "./types.ts" - -const profile = (userId: string) => `${org}/profile/${userId}` - -/** Org home (member directory), the chat index (channel browser) and user profiles. */ -export const homeArea: AreaModule = { - scenarios: [ - // Org home - { - id: "home-members", - area: "home", - title: "Org home member directory", - path: org, - viewports: ["desktop", "mobile"], - themes: ["light", "dark"], - }, - { - id: "home-members-menu", - area: "home", - title: "Member actions menu open", - path: org, - steps: async (page) => { - await page.getByRole("button", { name: "Member actions" }).first().click() - await page.getByRole("menuitem", { name: "Copy email" }).waitFor() - }, - }, - { - id: "home-members-search", - area: "home", - title: "Member search narrowed to one result", - path: org, - steps: async (page) => { - await page.getByRole("searchbox").fill("grace") - }, - }, - { - id: "home-members-search-empty", - area: "home", - title: "Member search with no matches", - path: org, - steps: async (page) => { - await page.getByRole("searchbox").fill("nobody-matches-this") - }, - }, - { - id: "home-members-overflow", - area: "home", - title: "Member directory with many long names", - path: org, - dataset: "personal-overflow", - viewports: ["desktop", "mobile"], - }, - // Chat index - { - id: "chat-index", - area: "home", - title: "All channels, public tab", - path: `${org}/chat`, - viewports: ["desktop", "mobile"], - themes: ["light", "dark"], - }, - { - id: "chat-index-private", - area: "home", - title: "All channels, private tab", - path: `${org}/chat`, - steps: async (page) => { - await page.getByRole("tab", { name: /Private/ }).click() - }, - }, - { - id: "chat-index-dms", - area: "home", - title: "All channels, direct messages tab", - path: `${org}/chat`, - steps: async (page) => { - await page.getByRole("tab", { name: /Direct messages/ }).click() - }, - }, - { - id: "chat-index-overflow", - area: "home", - title: "All channels with long names, unread badges and a muted channel", - path: `${org}/chat`, - dataset: "personal-overflow", - viewports: ["desktop", "mobile"], - }, - // Profiles - { - id: "profile-other", - area: "home", - title: "Another member's profile", - path: profile(defaultIds.user("grace")), - dataset: "personal-presence", - viewports: ["desktop", "mobile"], - themes: ["light", "dark"], - }, - { - id: "profile-own", - area: "home", - title: "Own profile with the Edit Profile link", - path: profile(defaultIds.user("ada")), - dataset: "personal-presence", - }, - { - id: "profile-busy", - area: "home", - title: "Profile of a busy member", - path: profile(defaultIds.user("margaret")), - dataset: "personal-presence", - }, - { - id: "profile-offline", - area: "home", - title: "Profile of an offline member", - path: profile(defaultIds.user("linus")), - dataset: "personal-presence", - }, - { - id: "profile-long-name", - area: "home", - title: "Profile with a long name and email", - path: profile(personalIds.longMember), - dataset: "personal-overflow", - viewports: ["desktop", "mobile"], - }, - { - id: "profile-not-found", - area: "home", - title: "Profile of an unknown user", - path: profile(personalIds.missingUser), - }, - ], -} diff --git a/packages/ui-parity/src/scenarios/integrations.ts b/packages/ui-parity/src/scenarios/integrations.ts deleted file mode 100644 index ebbedb50b..000000000 --- a/packages/ui-parity/src/scenarios/integrations.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { - ChannelWebhookListResponse, - ChatSyncChannelLinkListResponse, - GitHubSubscriptionListResponse, - RssSubscriptionListResponse, -} from "@hazel/domain/rpc" -import { defaultIds } from "../fixtures/datasets/default.ts" -import { org, type AreaModule, type Scenario } from "./types.ts" - -const s = (scenario: Omit): Scenario => ({ area: "settings", ...scenario }) -const integrations = `${org}/settings/integrations` -const channelIntegrations = `${org}/channels/${defaultIds.channel("general")}/settings/integrations` - -const integrationDetail: Scenario[] = [ - s({ id: "settings-integration-linear", title: "Linear integration, not connected", path: `${integrations}/linear` }), - s({ id: "settings-integration-linear-connected", title: "Linear integration, connected", path: `${integrations}/linear`, dataset: "integrations", themes: ["light", "dark"] }), - s({ - id: "settings-integration-oauth-error", - title: "Linear integration, OAuth callback failed", - path: `${integrations}/linear?connection_status=error&error_code=token_exchange_failed`, - steps: async (page) => { - await page.getByText("Failed to connect to Linear").waitFor() - }, - }), - s({ id: "settings-integration-craft", title: "Craft integration, API key form", path: `${integrations}/craft` }), -] - -const installed: Scenario[] = [ - s({ id: "settings-integrations-installed", title: "Installed apps", path: `${integrations}/installed`, dataset: "integrations", themes: ["light", "dark"] }), - s({ id: "settings-integrations-installed-empty", title: "Installed apps, none", path: `${integrations}/installed` }), - s({ - id: "settings-integrations-install-by-id", - title: "Installed apps, install by ID dialog", - path: `${integrations}/installed`, - steps: async (page) => { - await page.getByRole("button", { name: "Install by ID" }).click() - await page.getByRole("heading", { name: "Install Application by ID" }).waitFor() - }, - }), - s({ - id: "settings-integrations-install-by-id-error", - title: "Installed apps, install by ID fails", - path: `${integrations}/installed`, - dataset: "errors", - steps: async (page) => { - await page.getByRole("button", { name: "Install by ID" }).click() - await page.getByRole("textbox", { name: "Application ID" }).fill("00000000-0000-4000-8000-000000000000") - await page.getByRole("button", { name: "Install", exact: true }).click() - }, - }), - s({ - id: "settings-integrations-uninstall-error", - title: "Installed apps, uninstall fails", - path: `${integrations}/installed`, - dataset: "errors", - steps: async (page) => { - await page.getByRole("button", { name: "Uninstall" }).first().click() - await page.getByText("Application not found").waitFor() - }, - }), -] - -const marketplace: Scenario[] = [ - s({ id: "settings-integrations-marketplace", title: "Marketplace", path: `${integrations}/marketplace`, dataset: "integrations", themes: ["light", "dark"] }), - s({ id: "settings-integrations-marketplace-empty", title: "Marketplace, no public apps", path: `${integrations}/marketplace` }), - s({ - id: "settings-integrations-marketplace-search-empty", - title: "Marketplace, search without results", - path: `${integrations}/marketplace`, - dataset: "integrations", - steps: async (page) => { - await page.getByRole("textbox", { name: "Search applications..." }).fill("no such app") - await page.getByText("Try a different search term").waitFor() - }, - }), - s({ - id: "settings-integrations-marketplace-install-error", - title: "Marketplace, install fails", - path: `${integrations}/marketplace`, - dataset: "errors", - steps: async (page) => { - await page.getByRole("button", { name: "Install", exact: true }).first().click() - await page.getByText("Already installed", { exact: true }).waitFor() - }, - }), -] - -const yourApps: Scenario[] = [ - s({ id: "settings-integrations-your-apps", title: "Your apps", path: `${integrations}/your-apps`, dataset: "integrations", themes: ["light", "dark"] }), - s({ id: "settings-integrations-your-apps-empty", title: "Your apps, none created", path: `${integrations}/your-apps` }), - s({ - id: "settings-integrations-your-apps-menu", - title: "Your apps, bot actions menu", - path: `${integrations}/your-apps`, - dataset: "integrations", - steps: async (page) => { - await page.getByRole("button", { name: "Bot actions" }).first().click() - await page.getByRole("menuitem", { name: "Regenerate Token" }).waitFor() - }, - }), - s({ - id: "settings-integrations-your-apps-delete", - title: "Your apps, delete confirmation", - path: `${integrations}/your-apps`, - dataset: "integrations", - steps: async (page) => { - await page.getByRole("button", { name: "Bot actions" }).first().click() - await page.getByRole("menuitem", { name: "Delete" }).click() - await page.getByRole("heading", { name: "Delete Application" }).waitFor() - }, - }), - s({ - id: "settings-integrations-your-apps-create", - title: "Your apps, create application dialog", - path: `${integrations}/your-apps`, - steps: async (page) => { - await page.getByRole("button", { name: "Create Application" }).first().click() - await page.getByRole("heading", { name: "Create Application" }).waitFor() - }, - }), -] - -const channelIntegrationScenarios: Scenario[] = [ - s({ id: "channel-settings-integrations", title: "Channel integrations, no webhooks", path: channelIntegrations }), - s({ id: "channel-settings-integrations-webhooks", title: "Channel integrations with webhooks", path: channelIntegrations, dataset: "integrations", themes: ["light", "dark"] }), - s({ - id: "channel-settings-integrations-error", - title: "Channel integrations, webhooks fail to load", - path: channelIntegrations, - dataset: "errors", - steps: async (page) => { - await page.getByText("Channel not found").waitFor() - }, - }), -] - -export const integrationsArea: AreaModule = { - scenarios: [ - { - id: "settings-integrations", - area: "settings", - title: "Integrations", - path: `${org}/settings/integrations`, - }, - ...integrationDetail, - ...installed, - ...marketplace, - ...yourApps, - ...channelIntegrationScenarios, - ], - // Empty lists for every dataset; the `integrations` dataset overrides them with data. - rpc: () => ({ - "channelWebhook.list": () => new ChannelWebhookListResponse({ data: [] }), - "channelWebhook.listByOrganization": () => new ChannelWebhookListResponse({ data: [] }), - "chatSync.channelLink.list": () => new ChatSyncChannelLinkListResponse({ data: [] }), - "rssSubscription.list": () => new RssSubscriptionListResponse({ data: [] }), - "githubSubscription.list": () => new GitHubSubscriptionListResponse({ data: [] }), - }), -} diff --git a/packages/ui-parity/src/scenarios/my-settings.ts b/packages/ui-parity/src/scenarios/my-settings.ts deleted file mode 100644 index bcbe0cfff..000000000 --- a/packages/ui-parity/src/scenarios/my-settings.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { deflateSync } from "node:zlib" -import type { Page } from "playwright" -import { inboxDataset } from "../fixtures/datasets/inbox.ts" -import { - personalOverflowDataset, - personalPrefsDataset, - personalPresenceDataset, -} from "../fixtures/datasets/personal.ts" -import { reseedRandom } from "../runtime/stabilize.ts" -import { org, type AreaModule } from "./types.ts" - -const crcTable = Array.from({ length: 256 }, (_, n) => { - let c = n - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 - return c >>> 0 -}) -const crc32 = (bytes: Buffer) => { - let c = 0xffffffff - for (const byte of bytes) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8) - return (c ^ 0xffffffff) >>> 0 -} -const pngChunk = (type: string, data: Buffer) => { - const body = Buffer.concat([Buffer.from(type, "ascii"), data]) - const out = Buffer.alloc(body.length + 8) - out.writeUInt32BE(data.length, 0) - body.copy(out, 4) - out.writeUInt32BE(crc32(body), body.length + 4) - return out -} - -/** A deterministic 96x96 PNG: two vertical colour bands, so the crop area is visible. */ -const avatarPng = (() => { - const size = 96 - const header = Buffer.alloc(13) - header.writeUInt32BE(size, 0) - header.writeUInt32BE(size, 4) - header.set([8, 2, 0, 0, 0], 8) - const rows = Buffer.concat( - Array.from({ length: size }, () => - Buffer.from([ - 0, - ...Array.from({ length: size }, (_, x) => - x < size / 2 ? [37, 99, 235] : [249, 115, 22], - ).flat(), - ]), - ), - ) - return Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - pngChunk("IHDR", header), - pngChunk("IDAT", deflateSync(rows)), - pngChunk("IEND", Buffer.alloc(0)), - ]) -})() - -const openCropDialog = async (page: Page) => { - const chooser = page.waitForEvent("filechooser") - await page.getByRole("button", { name: "Change profile picture" }).click() - await (await chooser).setFiles({ name: "avatar.png", mimeType: "image/png", buffer: avatarPng }) - await page.getByRole("dialog").getByText("Crop profile picture").waitFor() -} - -const settings = `${org}/my-settings` - -export const mySettingsArea: AreaModule = { - datasets: [inboxDataset, personalPrefsDataset, personalOverflowDataset, personalPresenceDataset], - scenarios: [ - { - id: "my-settings-profile", - area: "settings", - title: "Profile settings", - path: `${settings}/profile`, - }, - { - id: "my-settings-notifications", - area: "settings", - title: "Notification preferences", - path: `${settings}/notifications`, - }, - // Appearance (my-settings index) - { - id: "my-settings-appearance", - area: "settings", - title: "Appearance settings", - path: settings, - themes: ["light", "dark"], - }, - { - id: "my-settings-appearance-mobile", - area: "settings", - title: "Appearance settings on mobile", - path: settings, - viewports: ["mobile"], - }, - { - id: "my-settings-appearance-remix", - area: "settings", - title: "Appearance with generated remix options", - path: settings, - steps: async (page) => { - // Remix colors come from Math.random; reseed so they depend only on the generator. - await reseedRandom(page) - await page.getByRole("button", { name: "Generate" }).click() - await page - .getByText("Click generate to create random theme combinations.") - .waitFor({ state: "detached" }) - }, - }, - { - id: "my-settings-appearance-display-mode", - area: "settings", - title: "Appearance scrolled to display mode", - path: settings, - steps: async (page) => { - await page.getByText("Switch between light and dark modes.").scrollIntoViewIfNeeded() - }, - }, - // Desktop (web fallback) - { - id: "my-settings-desktop", - area: "settings", - title: "Desktop settings, web fallback", - path: `${settings}/desktop`, - themes: ["light", "dark"], - }, - { - id: "my-settings-desktop-mobile", - area: "settings", - title: "Desktop settings on mobile", - path: `${settings}/desktop`, - viewports: ["mobile"], - }, - // Linked accounts - { - id: "my-settings-linked-accounts", - area: "settings", - title: "Linked accounts, Discord not linked", - path: `${settings}/linked-accounts`, - themes: ["light", "dark"], - }, - { - id: "my-settings-linked-accounts-linked", - area: "settings", - title: "Linked accounts, Discord linked", - path: `${settings}/linked-accounts`, - dataset: "inbox", - viewports: ["desktop", "mobile"], - }, - { - id: "my-settings-linked-accounts-error", - area: "settings", - title: "Linked accounts after a failed Discord link", - path: `${settings}/linked-accounts?connection_status=error&provider=discord&error_code=access_denied`, - steps: async (page) => { - await page.getByText("Failed to link Discord account").waitFor() - }, - }, - // Profile - { - id: "my-settings-profile-responsive", - area: "settings", - title: "Profile settings on mobile", - path: `${settings}/profile`, - viewports: ["mobile"], - themes: ["light", "dark"], - }, - { - id: "my-settings-profile-avatar-hover", - area: "settings", - title: "Profile picture hovered shows the edit overlay", - path: `${settings}/profile`, - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("button", { name: "Change profile picture" }).hover() - }, - }, - { - id: "my-settings-profile-crop-dialog", - area: "settings", - title: "Avatar crop dialog after choosing an image", - path: `${settings}/profile`, - steps: openCropDialog, - }, - { - id: "my-settings-profile-timezone", - area: "settings", - title: "Timezone picker filtered and open", - path: `${settings}/profile`, - steps: async (page) => { - await page.getByRole("combobox").click() - await page.keyboard.press("ControlOrMeta+a") - await page.keyboard.type("New") - await page.getByRole("listbox").waitFor() - }, - }, - { - id: "my-settings-profile-edited", - area: "settings", - title: "Profile with an edited name enables Save", - path: `${settings}/profile`, - steps: async (page) => { - await page.getByRole("textbox").first().fill("Augusta Ada") - }, - }, - { - id: "my-settings-profile-invalid", - area: "settings", - title: "Profile with an empty last name shows a validation error", - path: `${settings}/profile`, - steps: async (page) => { - await page.getByRole("textbox").nth(1).fill("") - await page.getByRole("textbox").first().click() - }, - }, - { - id: "my-settings-profile-long-names", - area: "settings", - title: "Profile settings with a very long name", - path: `${settings}/profile`, - dataset: "personal-overflow", - }, - // Notification preferences - { - id: "my-settings-notifications-toggled", - area: "settings", - title: "Notification preferences with do-not-disturb and custom quiet hours", - path: `${settings}/notifications`, - dataset: "personal-prefs", - themes: ["light", "dark"], - }, - { - id: "my-settings-notifications-sounds-off", - area: "settings", - title: "Notification preferences with sounds disabled", - path: `${settings}/notifications`, - steps: async (page) => { - await page.getByRole("switch", { name: "Enable notification sounds" }).click({ force: true }) - }, - }, - { - id: "my-settings-notifications-mobile", - area: "settings", - title: "Notification preferences on mobile", - path: `${settings}/notifications`, - viewports: ["mobile"], - }, - ], -} diff --git a/packages/ui-parity/src/scenarios/navigation.ts b/packages/ui-parity/src/scenarios/navigation.ts deleted file mode 100644 index 11186d4f3..000000000 --- a/packages/ui-parity/src/scenarios/navigation.ts +++ /dev/null @@ -1,345 +0,0 @@ -import type { Page } from "playwright" -import { seedEmojiPicker } from "../fixtures/datasets/rich/emoji.ts" -import { - ADA_STATUS_EMOJI, - statusEmojiClearDataset, - statusEmojiDataset, - statusEmojiSaveDataset, -} from "../fixtures/datasets/status-emoji.ts" -import { statusSetDataset } from "../fixtures/datasets/status-set.ts" -import { chat, type AreaModule, type Scenario } from "./types.ts" - -const nav = (scenario: Omit & { path?: string }): Scenario => ({ - area: "navigation", - ...scenario, - path: scenario.path ?? chat("general"), -}) - -const openPalette = async (page: Page) => { - await page.getByText("Browse channels").first().click() - await page.getByRole("dialog", { name: "Command Menu" }).waitFor() -} - -/** Opens the command palette and follows one of its items to a sub-page. */ -const palettePage = (item: string) => async (page: Page) => { - await openPalette(page) - await page.getByRole("menuitem", { name: item }).click() -} - -/** - * The sidebar section "+" buttons are named "badge 13" (the plus icon's ), in section order: - * Channels, Projects, Direct Messages. Index into them until legacy gives them real labels. - */ -const sectionAddButton = (page: Page, index: number) => - page.getByRole("button", { name: "badge 13", exact: true }).nth(index) - -/** User menu > "Set status" opens the set-status modal. */ -const openSetStatus = async (page: Page) => { - await page.getByRole("button", { name: "Profile" }).click() - await page.getByRole("menuitem", { name: /Set status/ }).click() - await page.getByRole("heading", { name: "Set a status" }).waitFor() -} - -const openEmojiPicker = async (page: Page) => { - await seedEmojiPicker(page) - await openSetStatus(page) - await page.getByRole("button", { name: "Pick an emoji" }).click() - await page.getByRole("dialog", { name: "Emoji picker" }).waitFor() -} - -/** "Clear after" > "Choose date & time": today and the next full hour. */ -const chooseCustomExpiry = async (page: Page) => { - await openSetStatus(page) - await page.getByRole("button", { name: /Don't clear/ }).click() - await page.getByRole("option", { name: "Choose date & time" }).click() - await page.getByRole("button", { name: "Calendar" }).waitFor() -} - -const setStatusScenarios: ReadonlyArray<Scenario> = [ - nav({ - id: "nav-set-status-modal", - title: "Set status modal, empty", - themes: ["light", "dark"], - steps: openSetStatus, - }), - nav({ - id: "nav-set-status-emoji-picker", - title: "Set status modal, emoji picker open", - themes: ["light", "dark"], - steps: openEmojiPicker, - }), - nav({ - id: "nav-set-status-emoji-picker-custom", - title: "Set status modal, emoji picker with custom emojis", - dataset: "integrations", - steps: openEmojiPicker, - }), - nav({ - id: "nav-set-status-emoji-picked", - title: "Set status modal, an emoji picked enables Save", - steps: async (page) => { - await openEmojiPicker(page) - await page.getByRole("gridcell", { name: "Smileys & emotion 5" }).click() - await page.getByRole("dialog", { name: "Emoji picker" }).waitFor({ state: "detached" }) - }, - }), - nav({ - id: "nav-set-status-preset", - title: "Set status modal, a preset picked", - steps: async (page) => { - await openSetStatus(page) - await page.getByRole("button", { name: /In a meeting/ }).click() - await page.getByRole("button", { name: /In a meeting/, pressed: true }).waitFor() - }, - }), - nav({ - id: "nav-set-status-custom-expiry", - title: "Set status modal, clearing at a chosen date and time", - steps: chooseCustomExpiry, - }), - nav({ - id: "nav-set-status-calendar", - title: "Set status modal, expiry calendar open", - themes: ["light", "dark"], - steps: async (page) => { - await chooseCustomExpiry(page) - await page.getByRole("button", { name: "Calendar" }).click() - await page.getByRole("grid").waitFor() - }, - }), - nav({ - id: "nav-set-status-date-picked", - title: "Set status modal, a later day picked in the calendar", - steps: async (page) => { - await chooseCustomExpiry(page) - await page.getByRole("button", { name: "Calendar" }).click() - await page - .getByRole("button", { name: /\bMarch 20, 2026\b/ }) - .first() - .click() - await page.getByRole("grid").waitFor({ state: "detached" }) - }, - }), - nav({ - id: "nav-set-status-saved", - title: "Set status modal, saving a preset with an expiry", - steps: async (page) => { - await openSetStatus(page) - await page.getByRole("button", { name: /Commuting/ }).click() - await page.getByRole("button", { name: /Don't clear/ }).click() - await page.getByRole("option", { name: "1 hour" }).click() - await page.getByRole("button", { name: "Save" }).click() - await page.getByText("Status updated").waitFor() - }, - }), - nav({ - id: "nav-set-status-existing", - title: "Set status modal with a status already set", - dataset: "status-set", - steps: openSetStatus, - }), - nav({ - id: "nav-set-status-submitting", - title: "Set status modal while saving", - dataset: "status-set", - steps: async (page) => { - await openSetStatus(page) - await page.getByRole("button", { name: /Out sick/ }).click() - await page.getByRole("button", { name: "Save" }).click() - await page.getByRole("button", { name: "Saving..." }).waitFor() - }, - }), - nav({ - id: "nav-set-status-cleared", - title: "Set status modal, clearing the status", - dataset: "status-set", - steps: async (page) => { - await openSetStatus(page) - await page.getByRole("button", { name: "Clear status" }).click() - await page.getByText("Status cleared").waitFor() - }, - }), -] - -/** The status emoji next to the user's name in the user menu trigger. */ -const triggerEmoji = (page: Page, emoji: string) => - page.getByRole("button", { name: "Profile" }).getByText(emoji, { exact: true }) - -const statusEmojiScenarios: ReadonlyArray<Scenario> = [ - nav({ - id: "nav-status-emoji-trigger", - title: "User menu trigger with an emoji status", - dataset: "status-emoji", - themes: ["light", "dark"], - steps: async (page) => { - await triggerEmoji(page, ADA_STATUS_EMOJI).waitFor() - }, - }), - nav({ - id: "nav-status-emoji-menu", - title: "User menu open with an emoji status", - dataset: "status-emoji", - steps: async (page) => { - await triggerEmoji(page, ADA_STATUS_EMOJI).waitFor() - await page.getByRole("button", { name: "Profile" }).click() - await page - .getByRole("menuitem", { name: /Set status/ }) - .getByText(ADA_STATUS_EMOJI) - .waitFor() - }, - }), - nav({ - id: "nav-status-emoji-saved", - title: "User menu shows a newly saved emoji status", - dataset: "status-emoji-save", - steps: async (page) => { - await openSetStatus(page) - await page.getByRole("button", { name: /In a meeting/ }).click() - await page.getByRole("button", { name: "Save" }).click() - await page.getByText("Status updated").waitFor() - await triggerEmoji(page, "📅").waitFor() - }, - }), - nav({ - id: "nav-status-emoji-cleared", - title: "User menu drops the emoji after clearing the status", - dataset: "status-emoji-clear", - steps: async (page) => { - await triggerEmoji(page, ADA_STATUS_EMOJI).waitFor() - await openSetStatus(page) - await page.getByRole("button", { name: "Clear status" }).click() - await page.getByText("Status cleared").waitFor() - await triggerEmoji(page, ADA_STATUS_EMOJI).waitFor({ state: "detached" }) - }, - }), -] - -export const navigationArea: AreaModule = { - scenarios: [ - { - id: "nav-user-menu", - area: "navigation", - title: "User menu open", - path: chat("general"), - steps: async (page) => { - await page.getByRole("button", { name: "Profile" }).click() - }, - }, - { - id: "nav-org-switcher", - area: "navigation", - title: "Organization switcher open", - path: chat("general"), - steps: async (page) => { - await page - .getByRole("button", { name: /Hazel Labs/ }) - .first() - .click() - }, - }, - { - id: "nav-command-palette", - area: "navigation", - title: "Command palette", - path: chat("general"), - themes: ["light", "dark"], - steps: async (page) => { - await page.getByText("Browse channels").first().click() - }, - }, - nav({ - id: "nav-palette-search", - title: "Command palette: search messages page", - themes: ["light", "dark"], - steps: palettePage("Search messages"), - }), - nav({ - id: "nav-palette-create-channel", - title: "Command palette: create channel page", - steps: palettePage("Create channel"), - }), - nav({ - id: "nav-palette-join-channel", - title: "Command palette: join channel page", - steps: palettePage("Join channel"), - }), - nav({ - id: "nav-palette-status", - title: "Command palette: set status page", - steps: palettePage("Set status..."), - }), - nav({ - id: "nav-palette-appearance", - title: "Command palette: appearance page", - themes: ["light", "dark"], - steps: palettePage("Change appearance..."), - }), - nav({ - id: "nav-channel-menu", - title: "Sidebar channel actions menu", - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("row", { name: "general" }).getByRole("button", { name: "dots" }).click() - await page.getByRole("menu").waitFor() - }, - }), - nav({ - id: "nav-section-add-menu", - title: "Channels section add menu", - steps: async (page) => { - await sectionAddButton(page, 0).click() - await page.getByRole("menu").waitFor() - }, - }), - nav({ - id: "nav-create-channel-modal", - title: "Create channel modal", - themes: ["light", "dark"], - steps: async (page) => { - await sectionAddButton(page, 0).click() - await page.getByRole("menuitem", { name: "Create new channel" }).click() - await page.getByRole("dialog").waitFor() - }, - }), - nav({ - id: "nav-join-channel-modal", - title: "Join channel modal", - steps: async (page) => { - await sectionAddButton(page, 0).click() - await page.getByRole("menuitem", { name: "Join existing channel" }).click() - await page.getByRole("dialog").waitFor() - }, - }), - nav({ - id: "nav-new-dm-modal", - title: "New direct message modal", - themes: ["light", "dark"], - steps: async (page) => { - await sectionAddButton(page, 2).click() - await page.getByRole("dialog").waitFor() - }, - }), - nav({ - id: "nav-mobile-sidebar", - title: "Mobile: sidebar sheet opened from the channel header", - viewports: ["mobile"], - themes: ["light", "dark"], - steps: async (page) => { - await page.getByRole("main").getByRole("button", { name: "menu", exact: true }).click() - await page.getByRole("dialog").waitFor() - }, - }), - nav({ - id: "nav-mobile-menu", - title: "Mobile: sidebar sheet opened from the bottom navigation", - viewports: ["mobile"], - steps: async (page) => { - await page.getByRole("navigation").getByRole("button", { name: "menu Menu" }).click() - await page.getByRole("dialog").waitFor() - }, - }), - ...setStatusScenarios, - ...statusEmojiScenarios, - ], - datasets: [statusSetDataset, statusEmojiDataset, statusEmojiSaveDataset, statusEmojiClearDataset], -} diff --git a/packages/ui-parity/src/scenarios/notifications.ts b/packages/ui-parity/src/scenarios/notifications.ts deleted file mode 100644 index 6f8ba8694..000000000 --- a/packages/ui-parity/src/scenarios/notifications.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { org, type AreaModule, type Scenario } from "./types.ts" - -const inbox = `${org}/notifications` - -/** The inbox and its three category tabs. Datasets are registered by the my-settings area. */ -const tab = (key: "dms" | "general" | "threads", title: string): Scenario[] => [ - { - id: `notifications-${key}`, - area: "notifications", - title: `${title} notifications`, - path: `${inbox}/${key}`, - dataset: "inbox", - themes: ["light", "dark"], - }, - { - id: `notifications-${key}-empty`, - area: "notifications", - title: `${title} notifications, empty`, - path: `${inbox}/${key}`, - }, - { - id: `notifications-${key}-mobile`, - area: "notifications", - title: `${title} notifications on mobile`, - path: `${inbox}/${key}`, - dataset: "inbox", - viewports: ["mobile"], - }, -] - -export const notificationsArea: AreaModule = { - scenarios: [ - { id: "notifications", area: "notifications", title: "Notifications inbox", path: inbox }, - { - id: "notifications-all", - area: "notifications", - title: "Notifications inbox, read and unread across every time group", - path: inbox, - dataset: "inbox", - viewports: ["desktop", "mobile"], - themes: ["light", "dark"], - }, - { - id: "notifications-all-scrolled", - area: "notifications", - title: "Notifications inbox scrolled to older notifications", - path: inbox, - dataset: "inbox", - steps: async (page) => { - await page.getByRole("heading", { name: "Older" }).scrollIntoViewIfNeeded() - }, - }, - { - id: "notifications-all-hover", - area: "notifications", - title: "Hovered notification row", - path: inbox, - dataset: "inbox", - steps: async (page) => { - await page.getByText("Do you have ten minutes before the planning call?").hover() - }, - }, - ...tab("dms", "Direct message"), - ...tab("general", "Channel"), - ...tab("threads", "Thread"), - ], -} diff --git a/packages/ui-parity/src/scenarios/settings.ts b/packages/ui-parity/src/scenarios/settings.ts deleted file mode 100644 index 6a24188f1..000000000 --- a/packages/ui-parity/src/scenarios/settings.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { ConnectInviteListResponse } from "@hazel/domain/rpc" -import type { Page } from "playwright" -import { chatSyncDiscordDataset } from "../fixtures/datasets/chat-sync-discord.ts" -import { defaultIds } from "../fixtures/datasets/default.ts" -import { errorsDataset } from "../fixtures/datasets/errors.ts" -import { chatSyncIds } from "../fixtures/datasets/integrations-rpc.ts" -import { integrationsDataset, LONG_EMOJI_NAME, longChannelId } from "../fixtures/datasets/integrations.ts" -import { memberDataset, memberEmptyDataset } from "../fixtures/datasets/member.ts" -import { org, type AreaModule, type Scenario } from "./types.ts" - -const channelSettings = (channelId: string, tab: string) => `${org}/channels/${channelId}/settings/${tab}` -const general = defaultIds.channel("general") -const design = defaultIds.channel("design") - -const s = (scenario: Omit<Scenario, "area">): Scenario => ({ area: "settings", ...scenario }) - -const customEmojis: Scenario[] = [ - s({ id: "settings-custom-emojis", title: "Custom emojis, admin with emojis", path: `${org}/settings/custom-emojis`, dataset: "integrations", themes: ["light", "dark"] }), - s({ id: "settings-custom-emojis-empty", title: "Custom emojis, none uploaded", path: `${org}/settings/custom-emojis` }), - s({ - id: "settings-custom-emojis-delete", - title: "Custom emojis, delete confirmation", - path: `${org}/settings/custom-emojis`, - dataset: "integrations", - steps: async (page) => { - await page.getByRole("row", { name: /:shipit:/ }).getByRole("button").click() - await page.getByRole("heading", { name: "Delete custom emoji" }).waitFor() - }, - }), - s({ - id: "settings-custom-emojis-delete-error", - title: "Custom emojis, delete fails", - path: `${org}/settings/custom-emojis`, - dataset: "errors", - steps: async (page) => { - await page.getByRole("row", { name: new RegExp(`:${LONG_EMOJI_NAME}:`) }).getByRole("button").click() - await page.getByRole("button", { name: "Delete emoji" }).click() - await page.getByText("Failed to delete emoji").waitFor() - }, - }), - s({ id: "settings-custom-emojis-member", title: "Custom emojis, member sees no admin controls", path: `${org}/settings/custom-emojis`, dataset: "member" }), - s({ id: "settings-custom-emojis-member-empty", title: "Custom emojis, member empty state", path: `${org}/settings/custom-emojis`, dataset: "member-empty" }), -] - -const connectInvites: Scenario[] = [ - s({ id: "settings-connect-invites", title: "Connect invitations, pending and declined", path: `${org}/settings/connect-invites`, dataset: "integrations", themes: ["light", "dark"] }), - s({ id: "settings-connect-invites-empty", title: "Connect invitations, none", path: `${org}/settings/connect-invites` }), - s({ - id: "settings-connect-invites-accept-error", - title: "Connect invitations, accepting fails", - path: `${org}/settings/connect-invites`, - dataset: "errors", - steps: async (page) => { - await page.getByRole("row", { name: /Acme Corporation/ }).getByRole("button", { name: "Accept" }).click() - await page.getByText("Cannot accept").waitFor() - }, - }), -] - -const chatSync: Scenario[] = [ - s({ id: "settings-chat-sync", title: "Chat sync, active, paused and failing connections", path: `${org}/settings/chat-sync`, dataset: "integrations", themes: ["light", "dark"] }), - s({ id: "settings-chat-sync-empty", title: "Chat sync, no connections", path: `${org}/settings/chat-sync` }), - s({ id: "settings-chat-sync-error", title: "Chat sync, connections fail to load", path: `${org}/settings/chat-sync`, dataset: "errors" }), - s({ - id: "settings-chat-sync-add-menu", - title: "Chat sync, add connection menu", - path: `${org}/settings/chat-sync`, - steps: async (page) => { - await page.getByRole("button", { name: "Add Third Party Connection" }).first().click() - await page.getByRole("menuitem", { name: "Discord" }).waitFor() - }, - }), - s({ - id: "settings-chat-sync-delete", - title: "Chat sync, delete connection confirmation", - path: `${org}/settings/chat-sync`, - dataset: "integrations", - steps: async (page) => { - // The trash icon's SVG <title> ("badge 13") is the button's accessible name; its `title` is ignored. - await page.getByRole("button", { name: "badge 13", exact: true }).first().click() - await page.getByRole("heading", { name: "Delete Connection" }).waitFor() - }, - }), -] - -const connection = (key: keyof typeof chatSyncIds) => `${org}/settings/chat-sync/${chatSyncIds[key]}` -const chatSyncConnection: Scenario[] = [ - s({ id: "settings-chat-sync-connection", title: "Chat sync connection with channel links", path: connection("community"), dataset: "integrations", themes: ["light", "dark"] }), - s({ id: "settings-chat-sync-connection-not-found", title: "Chat sync connection, not found", path: connection("community") }), - s({ id: "settings-chat-sync-connection-error-status", title: "Chat sync connection in error state, no links", path: connection("legacy"), dataset: "integrations" }), - s({ id: "settings-chat-sync-connection-long-name", title: "Chat sync connection, paused with a long guild name", path: connection("friends"), dataset: "integrations" }), - s({ - id: "settings-chat-sync-connection-link-menu", - title: "Chat sync connection, channel link actions menu", - path: connection("community"), - dataset: "integrations", - steps: async (page) => { - await page.getByRole("button", { name: "Channel link actions" }).first().click() - await page.getByRole("menuitem", { name: "Remove link" }).waitFor() - }, - }), - s({ - id: "settings-chat-sync-connection-disconnect", - title: "Chat sync connection, disconnect confirmation", - path: connection("community"), - dataset: "integrations", - steps: async (page) => { - await page.getByRole("button", { name: "Disconnect" }).click() - await page.getByRole("heading", { name: "Disconnect from Discord" }).waitFor() - }, - }), -] - -const openAddConnection = async (page: Page) => { - await page.getByRole("button", { name: "Add Third Party Connection" }).first().click() - await page.getByRole("menuitem", { name: "Discord" }).click() - await page.getByRole("heading", { name: "Connect Discord Server" }).waitFor() -} - -/** Picks a guild in the open modal and presses Connect. */ -const connectGuild = (guild: RegExp) => async (page: Page) => { - await openAddConnection(page) - const dialog = page.getByRole("dialog") - await dialog.getByRole("button", { name: guild }).click() - await dialog.getByRole("button", { name: "Connect", exact: true }).click() -} - -const chatSyncAddConnection: Scenario[] = [ - s({ - id: "settings-chat-sync-add-connection", - title: "Chat sync, add connection modal lists Discord servers", - path: `${org}/settings/chat-sync`, - dataset: "chat-sync-discord", - themes: ["light", "dark"], - steps: async (page) => { - await openAddConnection(page) - await page.getByRole("button", { name: "Design Systems Guild" }).waitFor() - }, - }), - s({ - id: "settings-chat-sync-add-connection-unavailable", - title: "Chat sync, add connection modal before Discord is authorized", - path: `${org}/settings/chat-sync`, - steps: async (page) => { - await openAddConnection(page) - await page.getByText("Connect Discord first").waitFor() - }, - }), - s({ - id: "settings-chat-sync-add-connection-no-match", - title: "Chat sync, add connection search matches no server", - path: `${org}/settings/chat-sync`, - dataset: "chat-sync-discord", - steps: async (page) => { - await openAddConnection(page) - await page.getByRole("dialog").getByRole("textbox").fill("zzz") - await page.getByText("No Discord servers found").waitFor() - }, - }), - s({ - id: "settings-chat-sync-add-connection-selected", - title: "Chat sync, add connection with a server picked", - path: `${org}/settings/chat-sync`, - dataset: "chat-sync-discord", - steps: async (page) => { - await openAddConnection(page) - await page.getByRole("dialog").getByRole("textbox").fill("gam") - await page.getByRole("button", { name: "Weekend Gamers" }).click() - await page.getByRole("button", { name: "Change" }).waitFor() - }, - }), - s({ - id: "settings-chat-sync-add-connection-change", - title: "Chat sync, add connection after Change keeps the search", - path: `${org}/settings/chat-sync`, - dataset: "chat-sync-discord", - steps: async (page) => { - await openAddConnection(page) - await page.getByRole("dialog").getByRole("textbox").fill("gam") - await page.getByRole("button", { name: "Weekend Gamers" }).click() - await page.getByRole("button", { name: "Change" }).click() - await page.getByRole("dialog").getByRole("textbox").waitFor() - }, - }), - s({ - id: "settings-chat-sync-add-connection-submitting", - title: "Chat sync, add connection while connecting", - path: `${org}/settings/chat-sync`, - dataset: "chat-sync-discord", - steps: async (page) => { - await connectGuild(/^Weekend Gamers/)(page) - await page.getByRole("button", { name: "Connecting..." }).waitFor() - }, - }), - s({ - id: "settings-chat-sync-add-connection-created", - title: "Chat sync, add connection succeeds", - path: `${org}/settings/chat-sync`, - dataset: "chat-sync-discord", - steps: async (page) => { - await connectGuild(/^Design Systems Guild/)(page) - await page.getByText("Discord connection created").waitFor() - }, - }), - s({ - id: "settings-chat-sync-add-connection-exists", - title: "Chat sync, add connection to a server that is already connected", - path: `${org}/settings/chat-sync`, - dataset: "chat-sync-discord", - steps: async (page) => { - await connectGuild(/^Hazel Community/)(page) - await page.getByText("Connection already exists").waitFor() - }, - }), -] - -const openAddLink = async (page: Page) => { - await page.getByRole("button", { name: "Link Channel" }).first().click() - await page.getByRole("heading", { name: "Link Channel" }).waitFor() -} - -/** Picks a Hazel channel and a Discord channel in the open modal, then presses Link Channel. */ -const linkChannels = (hazel: string, discord: string) => async (page: Page) => { - await openAddLink(page) - const dialog = page.getByRole("dialog") - await dialog.getByRole("button", { name: hazel, exact: true }).first().click() - await dialog.getByRole("button", { name: "Change" }).waitFor() - await dialog.getByRole("button", { name: discord, exact: true }).click() - await dialog.getByRole("button", { name: "Link Channel" }).click() -} - -const chatSyncAddLink: Scenario[] = [ - s({ - id: "settings-chat-sync-link-channel", - title: "Chat sync connection, link channel modal", - path: connection("community"), - dataset: "chat-sync-discord", - themes: ["light", "dark"], - steps: async (page) => { - await openAddLink(page) - await page.getByRole("button", { name: "announcements" }).waitFor() - }, - }), - s({ - id: "settings-chat-sync-link-channel-unavailable", - title: "Chat sync connection, link channel modal when Discord channels fail to load", - path: connection("community"), - dataset: "integrations", - steps: async (page) => { - await openAddLink(page) - await page.getByText("Could not load Discord channels").waitFor() - }, - }), - s({ - id: "settings-chat-sync-link-channel-no-match", - title: "Chat sync connection, link channel searches match nothing", - path: connection("community"), - dataset: "chat-sync-discord", - steps: async (page) => { - await openAddLink(page) - const dialog = page.getByRole("dialog") - await dialog.getByRole("textbox").first().fill("zzz") - await dialog.getByRole("textbox").last().fill("zzz") - await page.getByText("No Discord channels found").waitFor() - }, - }), - s({ - id: "settings-chat-sync-link-channel-filled", - title: "Chat sync connection, link channel with both channels and a direction picked", - path: connection("community"), - dataset: "chat-sync-discord", - steps: async (page) => { - await openAddLink(page) - const dialog = page.getByRole("dialog") - await dialog.getByRole("textbox").first().fill("ran") - await dialog.getByRole("button", { name: "random", exact: true }).click() - await dialog.getByRole("button", { name: "announcements", exact: true }).click() - await dialog.getByRole("button", { name: /^Discord to Hazel/ }).click() - await dialog.getByRole("button", { name: "Change" }).nth(1).waitFor() - }, - }), - s({ - id: "settings-chat-sync-link-channel-submitting", - title: "Chat sync connection, link channel while linking", - path: connection("community"), - dataset: "chat-sync-discord", - steps: async (page) => { - await linkChannels("random", "voice-chat-text")(page) - await page.getByRole("button", { name: "Linking..." }).waitFor() - }, - }), - s({ - id: "settings-chat-sync-link-channel-created", - title: "Chat sync connection, link channel succeeds", - path: connection("community"), - dataset: "chat-sync-discord", - steps: async (page) => { - await linkChannels("random", "announcements")(page) - await page.getByText("Linked #random to #announcements").waitFor() - }, - }), - s({ - id: "settings-chat-sync-link-channel-exists", - title: "Chat sync connection, link channel pair that is already linked", - path: connection("community"), - dataset: "chat-sync-discord", - steps: async (page) => { - await linkChannels("general", "general")(page) - await page.getByText("Link already exists").waitFor() - }, - }), -] - -const channelOverview: Scenario[] = [ - s({ id: "channel-settings-overview", title: "Channel settings overview", path: channelSettings(general, "overview"), themes: ["light", "dark"] }), - s({ - id: "channel-settings-overview-invalid", - title: "Channel settings overview, empty name", - path: channelSettings(general, "overview"), - steps: async (page) => { - await page.getByRole("textbox", { name: "Channel name" }).fill("") - }, - }), - s({ id: "channel-settings-overview-long-name", title: "Channel settings overview, long channel name", path: channelSettings(longChannelId, "overview"), dataset: "integrations" }), - s({ - id: "channel-settings-overview-save-error", - title: "Channel settings overview, saving fails", - path: channelSettings(general, "overview"), - dataset: "errors", - steps: async (page) => { - await page.getByRole("textbox", { name: "Channel name" }).fill("general-renamed") - await page.getByRole("button", { name: "Save changes" }).click() - await page.getByText("Channel not found").waitFor() - }, - }), -] - -const channelConnect: Scenario[] = [ - s({ id: "channel-settings-connect", title: "Channel connect, not shared", path: channelSettings(general, "connect") }), - s({ id: "channel-settings-connect-shared", title: "Channel connect, shared with invitations", path: channelSettings(design, "connect"), dataset: "integrations", themes: ["light", "dark"] }), - s({ - id: "channel-settings-connect-share-modal", - title: "Channel connect, share channel dialog", - path: channelSettings(general, "connect"), - steps: async (page) => { - await page.getByRole("button", { name: "Share channel" }).first().click() - await page.getByRole("heading", { name: "Share #general" }).waitFor() - }, - }), - s({ - id: "channel-settings-connect-revoke-error", - title: "Channel connect, revoking an invite fails", - path: channelSettings(design, "connect"), - dataset: "errors", - steps: async (page) => { - await page.getByRole("button", { name: "Revoke" }).click() - await page.getByText("Invite not found").waitFor() - }, - }), -] - -/** Organization settings and channel settings. */ -export const settingsArea: AreaModule = { - scenarios: [ - { id: "settings-general", area: "settings", title: "Organization settings", path: `${org}/settings` }, - { - id: "settings-team", - area: "settings", - title: "Team members", - path: `${org}/settings/team`, - themes: ["light", "dark"], - }, - { - id: "settings-invitations", - area: "settings", - title: "Invitations", - path: `${org}/settings/invitations`, - }, - { - id: "channel-settings", - area: "settings", - title: "Channel settings", - path: `${org}/channels/${defaultIds.channel("general")}/settings`, - }, - ...customEmojis, - // Production builds redirect /settings/debug to /settings, so this captures the redirect. - s({ id: "settings-debug", title: "Debug tools (redirects in production builds)", path: `${org}/settings/debug` }), - ...connectInvites, - ...chatSync, - ...chatSyncConnection, - ...chatSyncAddConnection, - ...chatSyncAddLink, - ...channelOverview, - ...channelConnect, - ], - datasets: [integrationsDataset, memberDataset, memberEmptyDataset, errorsDataset, chatSyncDiscordDataset], - rpc: () => ({ - "connectShare.invite.listIncoming": () => new ConnectInviteListResponse({ data: [] }), - "connectShare.invite.listOutgoing": () => new ConnectInviteListResponse({ data: [] }), - }), -} diff --git a/packages/ui-parity/src/scenarios/types.ts b/packages/ui-parity/src/scenarios/types.ts deleted file mode 100644 index 8555c045a..000000000 --- a/packages/ui-parity/src/scenarios/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Page } from "playwright" -import type { Dataset } from "../fixtures/dataset.ts" -import { defaultIds } from "../fixtures/datasets/default.ts" - -/** - * A scenario is one screen state, captured identically in both apps. - * - * Steps drive the page with accessible locators (`getByRole`, `getByText`), never - * CSS selectors or test IDs, so the same script runs against the React app and - * the Foldkit app. If a step cannot find its element in one app, that is itself a - * parity failure (wrong role, missing label) and is reported as such. - */ -export interface Scenario { - readonly id: string - readonly title: string - readonly path: string - readonly dataset?: string - readonly viewports?: ReadonlyArray<ViewportName> - readonly themes?: ReadonlyArray<ThemeName> - readonly steps?: (page: Page) => Promise<void> - /** Elements whose pixels are excluded from the diff (e.g. a live clock). Accessible locators only. */ - readonly mask?: (page: Page) => ReadonlyArray<ReturnType<Page["locator"]>> - /** Capture the full scroll height instead of the viewport. */ - readonly fullPage?: boolean - /** Tag for grouping in the report and for `--filter`. */ - readonly area: string -} - -export const viewports = { - desktop: { width: 1440, height: 900 }, - laptop: { width: 1280, height: 800 }, - mobile: { width: 390, height: 844 }, -} as const -export type ViewportName = keyof typeof viewports - -export type ThemeName = "light" | "dark" - -export type RpcHandlers = Readonly<Record<string, (payload: unknown) => unknown>> - -/** - * One file per area (`src/scenarios/<area>.ts`), aggregated by `src/scenarios.ts`, so parallel - * work on different areas never edits the same file. An area owns its scenarios, the datasets it - * introduces, and canned RPCs its screens need on every dataset. - */ -export interface AreaModule { - readonly scenarios: ReadonlyArray<Scenario> - readonly datasets?: ReadonlyArray<Dataset> - /** Merged over the built-in handlers in `backend/rpc.ts`; a dataset's own `rpc` still wins. */ - readonly rpc?: (dataset: Dataset) => RpcHandlers -} - -export const org = `/${defaultIds.orgSlug}` -export const chat = (key: Parameters<typeof defaultIds.channel>[0]) => `${org}/chat/${defaultIds.channel(key)}` diff --git a/packages/ui-parity/src/serve.ts b/packages/ui-parity/src/serve.ts deleted file mode 100644 index a528ef8e4..000000000 --- a/packages/ui-parity/src/serve.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { existsSync, readFileSync, statSync } from "node:fs" -import { join, normalize } from "node:path" -import { installClerkStub, type ClerkStubIdentity } from "./runtime/clerk-stub.ts" - -/** - * Static SPA server for a built target. Falls back to index.html for client - * routes and injects the Clerk stub as the first script, so the app boots signed - * in with no network access to Clerk, whether opened by Playwright or by hand. - * Captures set `window.__parityClerkIdentity` first to sign in as the scenario's dataset user. - */ -export const serveStatic = (root: string, port: number, identity: ClerkStubIdentity | null) => { - // A capture may inject `null` (signed out), so test presence rather than `??`. - const stub = `<script>(${installClerkStub.toString()})("__parityClerkIdentity" in window ? window.__parityClerkIdentity : ${JSON.stringify(identity)})</script>` - const indexHtml = readFileSync(join(root, "index.html"), "utf8").replace("<head>", `<head>${stub}`) - - return Bun.serve({ - port, - fetch(request) { - const { pathname } = new URL(request.url) - const filePath = normalize(join(root, decodeURIComponent(pathname))) - const isAsset = - pathname !== "/" && - filePath.startsWith(root) && - existsSync(filePath) && - statSync(filePath).isFile() - // Never serve the service worker: it would cache across runs. - if (pathname === "/sw.js" || pathname.startsWith("/workbox-")) - return new Response("", { status: 404 }) - if (isAsset && !filePath.endsWith("index.html")) return new Response(Bun.file(filePath)) - return new Response(indexHtml, { headers: { "content-type": "text/html; charset=utf-8" } }) - }, - }) -} diff --git a/packages/ui-parity/tsconfig.json b/packages/ui-parity/tsconfig.json deleted file mode 100644 index c64bfe7f1..000000000 --- a/packages/ui-parity/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "include": ["**/*.ts", "**/*.tsx"], - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - /* Linting */ - "skipLibCheck": true, - "strict": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "plugins": [ - { - "name": "@effect/language-service", - "reportSuggestionsAsWarningsInTsc": true - } - ] - } -} diff --git a/turbo.json b/turbo.json index 90f9cbcc6..173377b95 100644 --- a/turbo.json +++ b/turbo.json @@ -11,8 +11,6 @@ "BACKEND_URL", "INTERNAL_SECRET", "CLERK_SECRET_KEY", - "CLERK_PUBLISHABLE_KEY", - "CLERK_WEBHOOK_SECRET", "COOKIE_DOMAIN", "S3_BUCKET", "S3_ENDPOINT",